using System.Text.Json; using XFEEDToolBox.Core.Execution; using XFEEDToolBox.Core.Models; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public class TradeRecoveryTests { private sealed class ReadOnlyChannel(string identity,long credits,int cargo=0):IOriginalCommerceChannel { public string Identity=>identity; public readonly List Calls=[]; public Task CallAsync(object request,CancellationToken token) { token.ThrowIfCancellationRequested();var row=Check.Event(request);var op=row.GetProperty("op").GetString()!;Calls.Add(op); return Task.FromResult(op switch { "commerce.inventory"=>Check.Event(new{verified=true,credits,count=cargo,capacity=2}), "commerce.market.read"=>Check.Event(new{generation=1}), "commerce.market.status"=>Check.Event(new{state=3,cargo=cargo==0?new Dictionary():new(){["gold"]=cargo},serverCredits=credits}), _=>throw new InvalidOperationException("Reconciliation attempted a mutating request: "+op) }); } } private static readonly object Failure=new{state=4,error=500,mutationsSent=1,serviceKind=1,serverConfirmed=false,clientApplied=false}; private static async Task Save(string path,object value)=>await File.WriteAllTextAsync(path,JsonSerializer.Serialize(value)); private static async Task Pending(TemporaryDirectory temp,DateTimeOffset at,string identity="1:1") { var path=temp.File("original.pending.json");await Save(path,new{created=at,channel=identity,CommanderId="F1",CurrentShipId=7,intent=new{market=2,Commodity="gold",delta=-2,before=new{credits=9920,count=2,capacity=2,verified=true}}}); await Save(temp.File("original.status.json"),Failure);return path; } [Test] public async Task SameOriginalProcessRequiresOfficialRecoveryWithoutAnyTradeRequest() { using var temp=new TemporaryDirectory();var path=await Pending(temp,DateTimeOffset.UtcNow.AddMinutes(-2));var channel=new ReadOnlyChannel("1:1",9920,2);var caught=false; try{await NativeOrderReconciliation.WaitForResolutionAsync(path,channel,()=>new(),null,null,default);}catch(NativeOrderReconciliationRequiredException){caught=true;} Check.That(caught);Check.Equal(0,channel.Calls.Count);Check.That(!File.Exists(temp.File("original.not-applied-after-reload.json"))); } [Test] public async Task FreshOfficialSessionAndExactServerCargoProveOriginalRequestWasNotApplied() { using var temp=new TemporaryDirectory();var at=DateTimeOffset.UtcNow.AddMinutes(-2);var path=await Pending(temp,at);var identity=$"2:{at.AddMinutes(1).UtcTicks}"; var channel=new ReadOnlyChannel(identity,9920,2);var player=new PlayerState{CommanderId="F1",CurrentShipId=7,InGame=true,Docked=true,MarketId=2,CargoReliable=true,CreditsReliable=true,CargoCapacity=2,CargoCount=2,Credits=9920,LoadGameAt=at.AddMinutes(1)}; var pending=await File.ReadAllTextAsync(path);var receipt=await File.ReadAllTextAsync(temp.File("original.status.json")); await NativeOrderReconciliation.WaitForResolutionAsync(path,channel,()=>player,null,null,default); Check.That(NativeOrderReconciliation.IsResolved(path,player));Check.Equal(pending,await File.ReadAllTextAsync(path));Check.Equal(receipt,await File.ReadAllTextAsync(temp.File("original.status.json"))); Check.That(channel.Calls.SequenceEqual(new[]{"commerce.inventory","commerce.market.read","commerce.market.status"})); } [Test] public async Task DifferentWalletWaitsForEvidenceUntilCancellationAndNeverReplays() { using var temp=new TemporaryDirectory();var at=DateTimeOffset.UtcNow.AddMinutes(-2);var path=await Pending(temp,at);var channel=new ReadOnlyChannel($"2:{at.AddMinutes(1).UtcTicks}",9919,2); var player=new PlayerState{CommanderId="F1",CurrentShipId=7,InGame=true,Docked=true,MarketId=2,CargoReliable=true,CreditsReliable=true,CargoCapacity=2,CargoCount=2,Credits=9919,LoadGameAt=at.AddMinutes(1)}; using var stop=new CancellationTokenSource(150);var cancelled=false; try{await NativeOrderReconciliation.WaitForResolutionAsync(path,channel,()=>player,null,null,stop.Token);}catch(OperationCanceledException){cancelled=true;} Check.That(cancelled);Check.That(!NativeOrderReconciliation.IsResolved(path,player));Check.That(channel.Calls.All(op=>op=="commerce.inventory")); } private static async Task<(string Directory,PlayerState Player)> ManualFixture(TemporaryDirectory temp) { var at=DateTimeOffset.UtcNow.AddMinutes(-3);var directory=temp.File("journey-fixture");Directory.CreateDirectory(directory);Directory.CreateDirectory(temp.File("operations")); var bought=new ConfirmedTradeReceipt(Guid.NewGuid().ToString("N"),1,"gold",2,40,10000,9920,0,2,"F1",7,at.AddMinutes(-1),at,true,true); TradeJourneyCheckpoints.Save(Path.Combine(directory,"journey.json"),new("F1",7,at,bought));await Save(Path.Combine(directory,"leg.json"),new TradeLeg{From=new(){MarketId=1},To=new(){MarketId=2},Orders=[new("gold","Gold",2,40,100)]}); await Save(Path.Combine(directory,"opening-wallet.json"),new{credits=10000});await File.WriteAllTextAsync(Path.Combine(directory,"result.json"),"{\"passed\":false}"); var pending=temp.File("operations/original.pending.json");await Save(pending,new{created=at.AddSeconds(1),CommanderId="F1",CurrentShipId=7,intent=new{market=2,Commodity="gold",delta=-2}});await Save(temp.File("operations/original.status.json"),Failure); object[] rows=[new{timestamp=at.AddSeconds(-1),@event="LoadGame",FID="F1",ShipID=7},new{timestamp=at.AddSeconds(1),@event="Docked",MarketID=2}, new{timestamp=at.AddSeconds(2),@event="RefuelAll",Cost=23},new{timestamp=at.AddSeconds(3),@event="PayFines",Amount=40,ShipID=7}, new{timestamp=at.AddSeconds(4),@event="MarketSell",MarketID=2,Type="gold",Count=2,SellPrice=100,TotalSale=200,AvgPricePaid=40}]; await File.WriteAllLinesAsync(temp.File("Journal.fixture.log"),rows.Select(r=>JsonSerializer.Serialize(r)));await File.AppendAllTextAsync(temp.File("Journal.fixture.log"),"{partial"); return(directory,new(){InGame=true,Docked=true,CommanderId="F1",CurrentShipId=7,MarketId=3,CargoReliable=true,CreditsReliable=true,CargoCount=0,CargoCapacity=2,Credits=10057}); } [Test] public async Task ActualManualSaleAndPaidCostsSettleFinanciallyWithoutRewritingFailedNativeJourney() { using var temp=new TemporaryDirectory();var (directory,player)=await ManualFixture(temp);var channel=new ReadOnlyChannel("2:1",10057); await ExternalNativeSaleSettlement.ReconcileAsync(temp.Path,temp.Path,player,Check.Event(new{verified=true,count=0}),channel,default); await ExternalNativeSaleSettlement.ReconcileAsync(temp.Path,temp.Path,player,Check.Event(new{verified=true,count=0}),channel,default); Check.That(File.Exists(Path.Combine(directory,"settled.json")));Check.Equal("{\"passed\":false}",await File.ReadAllTextAsync(Path.Combine(directory,"result.json"))); using var ledger=new TradeProfitLedger(Path.Combine(directory,"ledger"));Check.Equal(57m,ledger.Snapshot.NetProfit);Check.Equal(40m,ledger.Snapshot.OtherExpenses);Check.Equal(23m,ledger.Snapshot.Fuel); Check.That(File.Exists(temp.File("operations/original.externally-settled.json")));Check.Equal(3,channel.Calls.Count); } [Test] public async Task ProvenManualSaleReleasesTradingButUnexplainedMoneyIsNotRecognizedAsAutomationProfit() { using var temp=new TemporaryDirectory();var (directory,player)=await ManualFixture(temp);var channel=new ReadOnlyChannel("2:1",10056);player=player with{Credits=10056}; await ExternalNativeSaleSettlement.ReconcileAsync(temp.Path,temp.Path,player,Check.Event(new{verified=true,count=0}),channel,default); Check.That(File.Exists(Path.Combine(directory,"settled.json")));Check.That(File.Exists(temp.File("operations/original.externally-settled.json"))); using var result=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory,"external-result.json"))); Check.That(result.RootElement.GetProperty("tradeSettled").GetBoolean());Check.That(!result.RootElement.GetProperty("passed").GetBoolean()); using var proof=JsonDocument.Parse(await File.ReadAllTextAsync(temp.File("operations/original.externally-settled.json")));Check.Equal(-1m,proof.RootElement.GetProperty("unexplainedWalletDifference").GetDecimal()); } [Test] public async Task LaterManualRoundTripExplainsCurrentWalletWithoutInflatingAutomationProfit() { using var temp=new TemporaryDirectory();var (directory,player)=await ManualFixture(temp);var at=DateTimeOffset.UtcNow.AddMinutes(-1); object[] later=[new{timestamp=at,@event="LoadGame",FID="F1",ShipID=7},new{timestamp=at.AddSeconds(1),@event="Docked",MarketID=2}, new{timestamp=at.AddSeconds(2),@event="MarketBuy",MarketID=2,Type="silver",Count=2,BuyPrice=40,TotalCost=80}, new{timestamp=at.AddSeconds(3),@event="MarketSell",MarketID=1,Type="silver",Count=2,SellPrice=100,TotalSale=200,AvgPricePaid=40},new{timestamp=at.AddSeconds(4),@event="RefuelAll",Cost=7}]; await File.AppendAllTextAsync(temp.File("Journal.fixture.log"),"\n");await File.AppendAllLinesAsync(temp.File("Journal.fixture.log"),later.Select(r=>JsonSerializer.Serialize(r))); player=player with{Credits=10170};var channel=new ReadOnlyChannel("2:1",10170); await ExternalNativeSaleSettlement.ReconcileAsync(temp.Path,temp.Path,player,Check.Event(new{verified=true,count=0}),channel,default); Check.That(File.Exists(Path.Combine(directory,"settled.json")));using var ledger=new TradeProfitLedger(Path.Combine(directory,"ledger"));Check.Equal(57m,ledger.Snapshot.NetProfit); using var proof=JsonDocument.Parse(await File.ReadAllTextAsync(temp.File("operations/original.externally-settled.json")));Check.Equal(113m,proof.RootElement.GetProperty("afterSettlementCashChange").GetDecimal()); } [Test] public async Task PaidStationFineClearsTheWaitButAnotherCommandersPaymentDoesNot() { using var temp=new TemporaryDirectory();object[] rows=[new{@event="LoadGame",FID="F1"},new{@event="Docked",MarketID=2,StationFaction=new{Name="Authority"}},new{@event="CommitCrime",Faction="Authority",Fine=400}, new{@event="LoadGame",FID="F2"},new{@event="PayFines",Faction="Authority",Amount=400,AllFines=true}]; await File.WriteAllLinesAsync(temp.File("Journal.fixture.log"),rows.Select(r=>JsonSerializer.Serialize(r))); Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(temp.Path,"F1",2,default)); await File.AppendAllLinesAsync(temp.File("Journal.fixture.log"),new[]{JsonSerializer.Serialize(new{@event="LoadGame",FID="F1"}),JsonSerializer.Serialize(new{@event="PayFines",Faction="Authority",Amount=400})}); Check.Equal(0L,await StationAccessWaiter.KnownFineAsync(temp.Path,"F1",2,default)); } [Test] public void CurrentAutomationProfitSurvivesRecoveryAndANewRunStartsFromZero() { using var temp=new TemporaryDirectory();var first=AutomaticTradeRunSession.Open(temp.Path,5000);Check.Equal(0m,first.Profit(5000)); var recovered=AutomaticTradeRunSession.Open(temp.Path,8000);Check.Equal(first.Id,recovered.Id);Check.Equal(3000m,recovered.Profit(8000)); AutomaticTradeRunSession.Finish(temp.Path);var next=AutomaticTradeRunSession.Open(temp.Path,8000);Check.That(first.Id!=next.Id);Check.Equal(0m,next.Profit(8000)); } }