using System.Text.Json; using XFEEDToolBox.Core.Execution; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Native; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public sealed class AutomaticTradeExpenseSnapshotTests { private const long OpeningCredits=1000000,ServiceCost=90164; private sealed class TravelFailure():InvalidOperationException("Fixture travel ended after confirmed maintenance."); private sealed class Commerce:IOriginalCommerceChannel { public string Identity{get;}=Guid.NewGuid().ToString("N"); public PlayerState Player=new(){Ship="Type9",InGame=true,Docked=true,MarketId=3,CommanderId="expense-snapshot-fixture",CurrentShipId=7, LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),CargoReliable=true,CreditsReliable=true,Credits=OpeningCredits,CargoCapacity=16,Rebuy=100, StationServicesKnown=true,StationServices=["refuel","repair"]}; public int Mutations; public bool FailWalletAfterFirstService; public long Wallet=>checked((long)Player.Credits!.Value); public readonly Dictionary Costs=new(){["refuel"]=38,["repair"]=89824,["integrity"]=302}; private ulong generation,quote; private readonly Dictionary replies=[]; private readonly Dictionary operations=[]; private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value); public Task CallAsync(object request,CancellationToken token) { token.ThrowIfCancellationRequested();var command=Json(request);var op=command.GetProperty("op").GetString(); if(op=="describe")return Task.FromResult(Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256, serviceQuoteSnapshots=true,formalCommodityTrading=true,commerceClientProfileRefresh=true,marketWalletOptional=true})); if(op=="commerce.inventory") { if(FailWalletAfterFirstService&&Mutations==1)throw new TravelFailure(); return Task.FromResult(Json(new{verified=true,count=Player.CargoCount,capacity=16,credits=Wallet})); } if(op=="commerce.market.status")return Task.FromResult(replies[command.GetProperty("generation").GetUInt64()]); if(op=="commerce.operation.status") return Task.FromResult(operations.TryGetValue(command.GetProperty("operationKey").GetString()!,out var known) ?Json(new{known=true,receiptAvailable=true,generation=known}):Json(new{known=false})); if(op=="station.services.inspect") { quote=++generation;replies[quote]=Json(new{state=3,generation=quote,servicePrices=new{fuel=new{costToRefuel=Costs["refuel"]}, repair=new{costToRepair=Costs["repair"],shipIntegrityCosts=new{costToRepair=Costs["integrity"],wear=0}},ammunition=new{costToRearm=0}}}); return Task.FromResult(Json(new{generation=quote,mutationsSent=0})); } if(op=="station.services") { Check.Equal(quote,command.GetProperty("quoteGeneration").GetUInt64()); var action=command.GetProperty("action").GetString()!;var cost=Costs[action];Check.That(cost>0); var before=Wallet;Player=Player with{Credits=before-cost};Costs[action]=0;Mutations++; var id=++generation;operations.Add(command.GetProperty("operationKey").GetString()!,id); replies[id]=Json(new{state=3,generation=id,error=0,mutationsSent=1,serverConfirmed=true,clientApplied=true,reconciliationRequired=false, creditsBefore=before,credits=Wallet,cargoBefore=Player.CargoCount,cargoAfter=Player.CargoCount,quantityDelta=0,unitPrice=cost}); return Task.FromResult(Json(new{generation=id})); } throw new InvalidOperationException("This expense fixture must not submit commodity orders: "+op); } } private static AutomaticTradeConfiguration Configuration(string root)=>new(){WorkingDirectory=root, Source=new(){MarketId=1,Name="Source",SystemName="System",MaxLandingPad=LandingPad.Large}, Destination=new(){MarketId=2,Name="Destination",SystemName="System",MaxLandingPad=LandingPad.Large}}; private static NativeTradeCommandDriver Driver(Commerce channel,string root,Func,CancellationToken,Task> travel) { var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(root,"operations"),travel,_=>Task.CompletedTask,batchPolicy:new(16,100)); driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver; } private static AutomaticTradeEngine Engine(Commerce channel,NativeTradeCommandDriver driver,string root) =>new(Configuration(root),driver,channel,()=>channel.Player,_=>Task.CompletedTask,root); private static void State(string root,string phase,decimal netProfit,string? active=null) { using var state=JsonDocument.Parse(File.ReadAllText(Path.Combine(root,"state.json")));var row=state.RootElement; Check.Equal(phase,row.GetProperty("phase").GetString());Check.Equal(netProfit,row.GetProperty("netProfit").GetDecimal()); Check.Equal(0,row.GetProperty("completedRounds").GetInt32());Check.Equal(0,row.GetProperty("completedBatches").GetInt32()); Check.Equal(active,row.GetProperty("activeJourney").GetString()); } private static async Task RequireTravelFailure(Task run) { var failed=false;try{await run.WaitAsync(TimeSpan.FromSeconds(5));}catch(TravelFailure){failed=true;} Check.That(failed,"The fixture must reach its intended post-service travel failure."); } [Test]public async Task ConfirmedExpensesAreVisibleDuringLongTravelAndFailureAndSurviveRestartExactlyOnce() { using var temporary=new TemporaryDirectory();var channel=new Commerce(); var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var finish=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var driver=Driver(channel,temporary.Path,async(_,_,token)=>{entered.TrySetResult(true);await finish.Task.WaitAsync(token);throw new TravelFailure();}); ConfirmedDockedServiceReceipt? fuel=null;driver.ServiceConfirmed+=receipt=>{if(receipt.Action=="refuel")fuel=receipt;}; var progress=new List();var engine=Engine(channel,driver,temporary.Path);engine.Progress+=progress.Add; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(10));var run=engine.RunAsync(timeout.Token); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); State(temporary.Path,"travelling",-ServiceCost);Check.Equal(3,channel.Mutations);Check.Equal(OpeningCredits-ServiceCost,channel.Wallet); Check.That(progress.Any(value=>value.Phase=="travelling"&&value.NetProfit==-ServiceCost&&value.CompletedRounds==0)); // Re-observing the identical durable receipt must not debit the cached snapshot again. Check.That(fuel is not null);driver.ObserveJourneyService(fuel!); } finally{finish.TrySetResult(true);} await RequireTravelFailure(run);State(temporary.Path,"requires-recovery",-ServiceCost); Check.Equal(3,Directory.GetFiles(temporary.File("route-ledger"),"*.entry.json").Length); Check.Equal(3,Directory.GetFiles(temporary.File("operations"),"*.confirmed.json").Length); // Restart reuses the server's paid quotes and the same durable ledger, without a new payment. var restarted=Engine(channel,Driver(channel,temporary.Path,(_,_,_)=>throw new TravelFailure()),temporary.Path); var restored=false;restarted.Progress+=value=>{if(value.Phase=="preflight"){Check.Equal(-ServiceCost,value.NetProfit);restored=true;}}; await RequireTravelFailure(restarted.RunAsync(timeout.Token));State(temporary.Path,"requires-recovery",-ServiceCost); Check.That(restored);Check.Equal(3,channel.Mutations);Check.Equal(OpeningCredits-ServiceCost,channel.Wallet); using var ledger=new TradeProfitLedger(temporary.File("route-ledger"));Check.Equal(-(decimal)ServiceCost,ledger.Snapshot.NetProfit); } [Test]public async Task CancellationAfterConfirmedMaintenanceKeepsTheActualDebitInStoppedState() { using var temporary=new TemporaryDirectory();var channel=new Commerce();using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5)); var driver=Driver(channel,temporary.Path,(_,_,token)=>{stop.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;}); await Engine(channel,driver,temporary.Path).RunAsync(stop.Token).WaitAsync(TimeSpan.FromSeconds(5)); State(temporary.Path,"stopped",-ServiceCost);Check.Equal(3,channel.Mutations); using var ledger=new TradeProfitLedger(temporary.File("route-ledger"));Check.Equal(-(decimal)ServiceCost,ledger.Snapshot.NetProfit); } [Test]public async Task FailureImmediatelyAfterOneConfirmedServicePublishesOnlyThatDebitAndResumesTheRemainingServices() { using var temporary=new TemporaryDirectory();var channel=new Commerce{FailWalletAfterFirstService=true}; await RequireTravelFailure(Engine(channel,Driver(channel,temporary.Path,(_,_,_)=>throw new TravelFailure()),temporary.Path).RunAsync(default)); State(temporary.Path,"requires-recovery",-38);Check.Equal(1,channel.Mutations);Check.Equal(OpeningCredits-38,channel.Wallet); Check.Equal(1,Directory.GetFiles(temporary.File("route-ledger"),"*.entry.json").Length); Check.Equal(1,Directory.GetFiles(temporary.File("operations"),"*.confirmed.json").Length); channel.FailWalletAfterFirstService=false; await RequireTravelFailure(Engine(channel,Driver(channel,temporary.Path,(_,_,_)=>throw new TravelFailure()),temporary.Path).RunAsync(default)); State(temporary.Path,"requires-recovery",-ServiceCost);Check.Equal(3,channel.Mutations);Check.Equal(OpeningCredits-ServiceCost,channel.Wallet); Check.Equal(3,Directory.GetFiles(temporary.File("route-ledger"),"*.entry.json").Length); } [Test]public async Task ActiveBatchMaintenanceStaysInItsOriginalLedgerWithoutCompletingOrDoubleChargingOnRestart() { using var temporary=new TemporaryDirectory();var channel=new Commerce(); channel.Player=channel.Player with{MarketId=1,CargoCount=1,Inventory=[new("gold","Gold",1)],Credits=OpeningCredits-100}; var journey=temporary.File("journey-active");Directory.CreateDirectory(journey);var started=DateTimeOffset.UtcNow.AddSeconds(-10); var leg=new TradeLeg{From=Configuration(temporary.Path).Source,To=Configuration(temporary.Path).Destination,Orders=[new("gold","Gold",1,100,200)]}; var bought=new ConfirmedTradeReceipt(Guid.NewGuid().ToString("N"),1,"gold",1,100,OpeningCredits,OpeningCredits-100,0,1, channel.Player.CommanderId,7,channel.Player.LoadGameAt!.Value,started.AddSeconds(1),true,true); TradeJourneyCheckpoints.Save(Path.Combine(journey,"journey.json"),new(channel.Player.CommanderId,7,started,bought)); await File.WriteAllTextAsync(Path.Combine(journey,"leg.json"),JsonSerializer.Serialize(leg,ToolboxSettings.JsonOptions)); await File.WriteAllTextAsync(Path.Combine(journey,"opening-wallet.json"),JsonSerializer.Serialize(new{credits=OpeningCredits})); for(var attempt=0;attempt<2;attempt++) { var travelled=false;var driver=Driver(channel,temporary.Path,(_,_,_)=> { travelled=true;State(temporary.Path,"cargo-journey",0,journey); Check.Equal(0,Directory.GetFiles(temporary.File("route-ledger"),"*.entry.json").Length); Check.Equal(4,Directory.GetFiles(Path.Combine(journey,"ledger"),"*.entry.json").Length); throw new TravelFailure(); }); var failed=false;try{await Engine(channel,driver,temporary.Path).RunAsync(default).WaitAsync(TimeSpan.FromSeconds(5));} catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is TravelFailure){failed=true;} Check.That(failed&&travelled);State(temporary.Path,"requires-recovery",0,journey);Check.Equal(3,channel.Mutations); Check.That(!File.Exists(Path.Combine(journey,"settled.json"))); } using var batchLedger=new TradeProfitLedger(Path.Combine(journey,"ledger")); Check.Equal(100m,batchLedger.Snapshot.CargoInvestment);Check.Equal((decimal)ServiceCost,batchLedger.Snapshot.Expenses); Check.Equal(OpeningCredits-100-ServiceCost,channel.Wallet); } }