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<string,long> Costs=new(){["refuel"]=38,["repair"]=89824,["integrity"]=302};
private ulong generation,quote;
private readonly Dictionary<ulong,JsonElement> replies=[];
private readonly Dictionary<string,ulong> operations=[];
private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value);
public Task<JsonElement> 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<Station,Func<CancellationToken,Task>,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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var finish=new TaskCompletionSource<bool>(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<AutomaticTradeProgress>();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);
}
}
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<string,long> Costs=new(){["refuel"]=38,["repair"]=89824,["integrity"]=302};
private ulong generation,quote;
private readonly Dictionary<ulong,JsonElement> replies=[];
private readonly Dictionary<string,ulong> operations=[];
private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value);
public Task<JsonElement> 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<Station,Func<CancellationToken,Task>,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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var finish=new TaskCompletionSource<bool>(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<AutomaticTradeProgress>();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);
}
}