XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text.Json;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;

namespace XFEEDToolBox.Test;

[TestFixture]
public class NativeTradeCommandDriverTests
{
    private sealed class Channel:IOriginalCommerceChannel
    {
        public string Identity{get;}=Guid.NewGuid().ToString();
        public PlayerState Player=new(){Ship="Type9",InGame=true,Docked=true,MarketId=1,CommanderId="native-commerce-test",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),Credits=10000,
            CreditsReliable=true,CargoReliable=true,CargoCapacity=16,StationServicesKnown=true,StationServices=["refuel","repair","rearm"]};
        public long Credits=10000;public int Cargo,Mutations,Overlapping,MaximumPending,DockingPendingReads,ReadinessProbes,MarketReads,ServiceReads;public bool FuelNeeded=true,DropAcknowledgement;
        public List<long> MarketReadMarkets=[];
        public ulong ActualMarket=1;public Action? OnReadinessProbe;
        public long RepairNeeded,IntegrityNeeded,RearmNeeded;public int IntegrityWear;public bool KeepServiceOutstanding;
        public bool QuoteSnapshotsSupported=true,FailPostTradeReadOnce;public int FreshnessFailures,PostTradeFreshnessFailures;public int ReadFailureCode=1;private bool currentQuote;private ulong quotedGeneration;private readonly HashSet<string> consumedServices=[];
        public Func<int,ulong,JsonElement?>? ServiceReplyFactory;
        public bool ProfileRefreshSupported=true,MarketWalletOptional=true,MarketHasCredits=true,MissingServerCargo;public long ServerCreditsOffset;public int ProfileRefreshes,ServerCargoOffset;
        public string Commodity="biowaste";public uint CommodityId=128049244;public int UnitBuy=68,UnitSell=80;public bool BatchSupported;
        public bool Goals;public long Contribution;public int CgReads;
        private ulong generation;private int pending;
        private readonly Dictionary<ulong,JsonElement> replies=[];private readonly Dictionary<string,ulong> keys=[];
        private static JsonElement Json(object value)=>JsonSerializer.SerializeToElement(value);
        public async Task<JsonElement> CallAsync(object request,CancellationToken token)
        {
            var command=Json(request);var op=command.GetProperty("op").GetString();
            if(op=="describe")return Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256,commerceDockedReadiness=true,cgCommodityBatches=BatchSupported,formalCommodityTrading=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported,commerceClientProfileRefresh=ProfileRefreshSupported,marketWalletOptional=MarketWalletOptional});
            if(op is "cg.read" or "cg.read.mine")
            {
                if(!Goals||command.GetProperty("marketId").GetInt64()!=99)throw new InvalidOperationException("CG was queried outside the configured destination");
                CgReads++;string Hex(string text)=>Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(text));
                var body=JsonSerializer.Serialize(new{initiatives=new Dictionary<string,object>{["42"]=new{id=42,activityType="tradelist",goalType="trade",market_id=99,
                    expiry=DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds(),complete=false,final_state="active",hidden="0",systemName=Hex("System99"),marketName=Hex("Station99"),
                    bulletin=new{title=Hex("Another trade CG")},@dynamic=new{commanderContributionQty=Contribution,commanderSubscribed=true,complete=false}}}});
                var id=++generation;replies[id]=Json(new{state=3,generation=id,kind=op=="cg.read.mine"?2:0,mutationsSent=0,fresh=true,responseBody=body,
                    proof=Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(body)))});return Json(new{generation=id});
            }
            if(op=="cg.status")return replies[command.GetProperty("generation").GetUInt64()];
            if(op=="commerce.inventory")return Json(new{verified=true,count=Cargo,capacity=Player.CargoCapacity,credits=Credits});
            if(op=="commerce.docked.status")
            {
                ReadinessProbes++;OnReadinessProbe?.Invoke();var ready=DockingPendingReads--<=0;
                return Json(new{ready,serviceReady=ready,actualMarketId=ActualMarket,stage=ready?"ready":"docking",mutationsSent=0});
            }
            if(op=="commerce.market.status")return replies[command.GetProperty("generation").GetUInt64()];
            if(op=="commerce.operation.status")
            {
                var key=command.GetProperty("operationKey").GetString()!;
                return keys.TryGetValue(key,out var known)?Json(new{known=true,receiptAvailable=true,generation=known}):Json(new{known=false});
            }
            if(op=="station.services.inspect")
            {
                ServiceReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Service prices requested before native docking settled.");
                var id=++generation;quotedGeneration=id;currentQuote=true;consumedServices.Clear();
                if(ServiceReplyFactory?.Invoke(ServiceReads,id) is {} overridden)
                {replies[id]=overridden;return Json(new{generation=id,mutationsSent=0});}
                replies[id]=Json(new{state=3,generation=id,servicePrices=new{fuel=new{costToRefuel=FuelNeeded?5:0},repair=new{costToRepair=RepairNeeded,shipIntegrityCosts=new{costToRepair=IntegrityNeeded,wear=IntegrityWear}},ammunition=new{costToRearm=RearmNeeded}}});return Json(new{generation=id});
            }
            if(op=="commerce.market.read")
            {
                MarketReadMarkets.Add(command.GetProperty("marketId").GetInt64());
                if(FailPostTradeReadOnce&&Cargo>0){FailPostTradeReadOnce=false;throw new IOException("Post-order read failed after original cargo/wallet confirmation.");}
                MarketReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Market requested before native docking settled.");
                currentQuote=false;
                if(command.TryGetProperty("refreshClient",out var refresh)&&refresh.ValueKind==JsonValueKind.True)ProfileRefreshes++;
                var id=++generation;
                if(FreshnessFailures-->0){replies[id]=Json(new{generation=id,state=4,error=ReadFailureCode,mutationsSent=0,reconciliationRequired=false});return Json(new{generation=id});}
                var response=new Dictionary<string,object?>{["state"]=3,["generation"]=id,["profileApplied"]=false,["serverCargoCount"]=Cargo+ServerCargoOffset,
                    ["commodityCount"]=1,["commodities"]=new[]{new{id=CommodityId,name=Commodity,buyPrice=UnitBuy,sellPrice=UnitSell,stock=100000,demand=100000}}};
                if(!MissingServerCargo)response["cargo"]=Cargo+ServerCargoOffset==0?new Dictionary<string,int>():new(){[Commodity]=Cargo+ServerCargoOffset};
                if(MarketHasCredits)response["serverCredits"]=Credits+ServerCreditsOffset;
                replies[id]=Json(response);return Json(new{generation=id});
            }
            if(op is "station.services" or "commerce.trade" or "commerce.trade.batch" or "commerce.trade.formal")
            {
                if(op=="station.services")
                {
                    var service=command.GetProperty("action").GetString()!;
                    var snapshot=command.TryGetProperty("quoteGeneration",out var quoteId);
                    if(snapshot? !QuoteSnapshotsSupported||quoteId.GetUInt64()!=quotedGeneration||consumedServices.Contains(service):!currentQuote)
                        throw new InvalidOperationException("Fresh original service quote required.");
                    consumedServices.Add(service);
                }
                currentQuote=false;
                var count=Interlocked.Increment(ref pending);MaximumPending=Math.Max(MaximumPending,count);if(count>1)Overlapping++;
                var before=Credits;var cargoBefore=Cargo;await Task.Delay(25,token);var trade=op is "commerce.trade" or "commerce.trade.batch" or "commerce.trade.formal";var delta=trade?command.GetProperty("quantityDelta").GetInt32():0;
                var action=trade?"":command.GetProperty("action").GetString();
                var price=trade?delta>0?UnitBuy:UnitSell:action switch{"refuel"=>5,"repair"=>Math.Max(RepairNeeded,10),"integrity"=>IntegrityNeeded,"rearm"=>Math.Max(RearmNeeded,10),_=>throw new InvalidOperationException("Unexpected service.")};
                Credits-=trade?price*delta:price;
                if(trade){if(Goals&&delta<0&&Player.MarketId==99)Contribution-=delta;Cargo+=delta;Player=Player with{CargoCount=Cargo,Inventory=Cargo>0?[new(Commodity,Commodity,Cargo)]:[]};}
                else if(!KeepServiceOutstanding)
                    switch(action){case "refuel":FuelNeeded=false;break;case "repair":RepairNeeded=0;break;case "integrity":IntegrityNeeded=0;IntegrityWear=0;break;case "rearm":RearmNeeded=0;break;}
                var id=++generation;var key=command.GetProperty("operationKey").GetString()!;Mutations++;keys[key]=id;
                if(trade&&PostTradeFreshnessFailures>0){FreshnessFailures=PostTradeFreshnessFailures;PostTradeFreshnessFailures=0;}
                replies[id]=Json(new{state=3,generation=id,serverConfirmed=true,clientApplied=true,creditsBefore=before,credits=Credits,cargoBefore,cargoAfter=Cargo,after=Cargo,unitPrice=price});Interlocked.Decrement(ref pending);
                if(DropAcknowledgement){DropAcknowledgement=false;throw new IOException("Lost acknowledgement after server acceptance.");}
                return Json(new{generation=id});
            }
            throw new InvalidOperationException("Unexpected native operation: "+op);
        }
    }
    private static NativeTradeCommandDriver Driver(Channel channel,string directory,NativeTradeBatchPolicy? batch=null,string? journals=null)
    {
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory,(_,_,_)=>throw new InvalidOperationException("No flight in this test."),_=>Task.CompletedTask,batchPolicy:batch){JournalDirectory=journals};
        driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
    }
    private static TradeAction Buy()=>new(TradeActionKind.Buy,new(){From=Check.Station(1),To=Check.Station(2),Orders=[new("biowaste","Biowaste",1,68,80)]},new("biowaste","Biowaste",1,68,80));
    private static Task WriteLocalFine(TemporaryDirectory directory,Channel channel)=>File.WriteAllLinesAsync(directory.File("Journal.fine.log"),new object[]
    {
        new{@event="LoadGame",FID=channel.Player.CommanderId},new{@event="Docked",MarketID=1,StationFaction=new{Name="Transit authority"}},
        new{@event="CommitCrime",Faction="Transit authority",Fine=400}
    }.Select(value=>JsonSerializer.Serialize(value)));
    [Test]public async Task ATransitPortFineCannotBlockSessionPreflightOrFlightToTheActualTradingStation()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel();await WriteLocalFine(directory,channel);var travelled=false;var messages=new List<string>();
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory.Path,
            (station,_,_)=>{Check.Equal(2L,station.MarketId);travelled=true;return Task.CompletedTask;},_=>Task.CompletedTask){JournalDirectory=directory.Path};
        driver.Status+=messages.Add;
        using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2));
        await driver.PreflightAsync(timeout.Token);
        await driver.ExecuteAsync(new(TradeActionKind.Travel,Buy().Leg,null),_=>Task.CompletedTask,timeout.Token);
        Check.That(travelled);Check.Equal(0,channel.Mutations);Check.Equal(0,messages.Count);
        Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(directory.Path,channel.Player.CommanderId,1,default));
    }
    [Test]public async Task TheSameFineStillBlocksAnActualPurchaseUntilPaidWithoutSubmittingOrRepeatingAnOrder()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel();await WriteLocalFine(directory,channel);
        var driver=Driver(channel,directory.Path,journals:directory.Path);var messages=new List<string>();driver.Status+=messages.Add;
        using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2)))await driver.PreflightAsync(timeout.Token);
        var cancelled=false;
        using(var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(200)))
            try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,stop.Token);}catch(OperationCanceledException){cancelled=true;}
        Check.That(cancelled);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(1,messages.Count);
        Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
        await File.AppendAllLinesAsync(directory.File("Journal.fine.log"),[JsonSerializer.Serialize(new{@event="PayFines",Faction="Transit authority",Amount=400})]);
        var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        Check.Equal(1,channel.Mutations);Check.Equal(1,receipt.QuantityDelta);
        Check.Equal(1,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test] public async Task TransientMarketFreshnessFailureRetriesTheReadWithoutDuplicatingTheTrade()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=2};var driver=Driver(channel,directory.Path);
        await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        Check.Equal(1,channel.Mutations);Check.Equal(1,receipt.QuantityDelta);Check.Equal(4,channel.MarketReads);
        Check.Equal(1,Directory.GetFiles(directory.Path,"*.pending.json").Length);
        Check.That(Directory.GetFiles(directory.Path,"*.pending.json").All(path=>new FileInfo(path).Length>0));
    }
    [Test] public async Task RepeatedMarketFreshnessFailuresStopAfterFourReadsBeforeAnyOrder()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=10};var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
        var stopped=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(NativeMarketSnapshotUnavailableException){stopped=true;}
        Check.That(stopped);Check.Equal(4,channel.MarketReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test] public async Task AConfirmedOrderSurvivesOptionalMarketFreshnessFailureWithoutResubmission()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{PostTradeFreshnessFailures=10};var driver=Driver(channel,directory.Path);
        var confirmed=new List<ConfirmedTradeReceipt>();driver.TradeConfirmed+=confirmed.Add;
        await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        Check.Equal(1,channel.Mutations);Check.Equal(5,channel.MarketReads);Check.Equal(1,confirmed.Count);
        Check.Equal(receipt,confirmed.Single());Check.Equal(1,receipt.CargoAfter);Check.Equal(9932m,(decimal)receipt.CreditsAfter);
        Check.Equal(1,Directory.GetFiles(directory.Path,"market-refresh-pending-*.json").Length);
        Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
        // The next purchase must still obtain its own fresh pre-order quote.
        var refused=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(NativeMarketSnapshotUnavailableException){refused=true;}
        Check.That(refused);Check.Equal(1,channel.Mutations);
    }
    [Test] public async Task MarketIdentityRejectionDoesNotUseTheFreshnessRetry()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{FreshnessFailures=10,ReadFailureCode=2};var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
        var stopped=false;try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidOperationException){stopped=true;}
        Check.That(stopped);Check.Equal(1,channel.MarketReads);Check.Equal(0,channel.Mutations);
    }
    [Test] public async Task TheKnownUnsupportedMarketSchemaIsRejectedBeforeCreatingAnyOrder()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true,MarketWalletOptional=false};var driver=Driver(channel,directory.Path,new(16,100));var refused=false;
        try{await driver.PreflightAsync(default);}catch(NativeTradeSdkUpgradeRequiredException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test] public async Task SourceQuoteCrossChecksTheActualClientWithoutInventingAProfile()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{MarketHasCredits=false};var driver=Driver(channel,directory.Path);
        await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        Check.Equal(0,channel.ProfileRefreshes);Check.Equal(1,channel.Mutations);Check.Equal(1,channel.Player.CargoCount);Check.Equal(9932L,channel.Credits);
    }
    [Test] public async Task ServerCargoMismatchCannotStartAnOrder()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{ServerCargoOffset=1};var driver=Driver(channel,directory.Path);
        await driver.PreflightAsync(default);var refused=false;
        try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test] public async Task MissingServerInventoryCannotBeTreatedAsAnEmptyHold()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{MissingServerCargo=true};var driver=Driver(channel,directory.Path);
        await driver.PreflightAsync(default);var refused=false;
        try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test] public async Task StaleClientWalletCannotBeUsedAsTheNextPurchaseBalance()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{ServerCreditsOffset=-26};var driver=Driver(channel,directory.Path);
        await driver.PreflightAsync(default);var refused=false;
        try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test] public async Task ParallelTradeAndMaintenanceShareTheActualWalletAcrossDrivers()
    {
        var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();
        var one=Driver(channel,directory);var two=Driver(channel,directory);await one.PreflightAsync(default);await two.PreflightAsync(default);
        var services=new List<ConfirmedDockedServiceReceipt>();one.ServiceConfirmed+=services.Add;
        var maintenance=one.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
        var trade=two.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        await Task.WhenAll(maintenance,trade);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Overlapping);Check.Equal(1,channel.MaximumPending);
        Check.Equal(9927L,channel.Credits);Check.Equal(9927m,channel.Player.Credits);Check.Equal(1,channel.Player.CargoCount);
        Check.Equal(2,Directory.GetFiles(directory,"*.confirmed.json").Length);
        Check.Equal(1,services.Count);Check.Equal(5m,services[0].ToCashEntry().Amount);Check.Equal(TradeCashKind.Fuel,services[0].ToCashEntry().Kind);
    }
    [Test] public async Task LostAcknowledgementQueriesTheSameOrderWithoutChargingAgain()
    {
        var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel{DropAcknowledgement=true,FuelNeeded=false};
        var driver=Driver(channel,directory);await driver.PreflightAsync(default);var receipt=await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        Check.Equal(1,channel.Mutations);Check.Equal(9932L,receipt.CreditsAfter);Check.Equal(1,receipt.CargoAfter);Check.Equal(1,Directory.GetFiles(directory,"*.confirmed.json").Length);
    }
    [Test] public async Task StationsWithoutMaintenanceDoNotSubmitAnyServiceRequests()
    {
        var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();channel.Player=channel.Player with{StationServices=[]};
        var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);Check.Equal(0,channel.Mutations);
        Check.Equal(0,channel.ServiceReads);
    }
    [Test] public async Task AllServicesUseOneQuoteAndOneFinalCompletionRead()
    {
        var channel=new Channel{RepairNeeded=12,IntegrityNeeded=17,IntegrityWear=1,RearmNeeded=4};
        var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-batch-"+Guid.NewGuid().ToString("N")));
        var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;await driver.PreflightAsync(default);
        await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
        Check.Equal(2,channel.ServiceReads);Check.Equal(4,channel.Mutations);Check.Equal(4,receipts.Count);Check.Equal(9956L,channel.Credits);
        Check.Equal(44m,receipts.Sum(receipt=>receipt.ToCashEntry().Amount));Check.Equal(1,channel.MaximumPending);
    }
    [Test] public async Task AlreadyMaintainedShipNeedsOnlyOneReadAndNoOrders()
    {
        var channel=new Channel{FuelNeeded=false};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-clean-"+Guid.NewGuid().ToString("N")));
        await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
        Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
    }
    private static JsonElement ServiceReadFailure(ulong generation,int mutations=0,bool reconciliation=false,int kind=3,long market=1,int error=1)
        =>JsonSerializer.SerializeToElement(new{state=4,generation,marketId=market,error,mutationsSent=mutations,reconciliationRequired=reconciliation,
            serviceKind=kind,quantityDelta=0,serverConfirmed=false,clientApplied=false});
    [Test]public async Task StaleServiceReadRecoversBeforeOneMaintenanceOrderAndPersistsReadOnlyEvidence()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{ServiceReplyFactory=(attempt,id)=>attempt<=2?ServiceReadFailure(id):null};
        var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);
        await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
        Check.Equal(4,channel.ServiceReads);Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);
        Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
        var failures=Directory.GetFiles(directory.Path,"service-readiness-*.json");Check.Equal(2,failures.Length);
        foreach(var file in failures){using var json=JsonDocument.Parse(File.ReadAllText(file));Check.That(json.RootElement.GetProperty("readOnly").GetBoolean()
            &&json.RootElement.GetProperty("safeToRepeatRead").GetBoolean()&&!json.RootElement.GetProperty("monetaryRequestReplayed").GetBoolean());}
    }
    [Test]public async Task ServiceReadinessBudgetStopsAtEightUnsubmittedReadsWithoutAnOrder()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{ServiceReplyFactory=(_,id)=>ServiceReadFailure(id)};
        var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);NativeServiceSnapshotUnavailableException? failure=null;
        try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(NativeServiceSnapshotUnavailableException error){failure=error;}
        Check.That(failure is not null&&failure.Attempts==NativeServiceSnapshotUnavailableException.MaximumAttempts&&File.Exists(failure.DiagnosticPath));
        Check.Equal(8,channel.ServiceReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
    }
    [Test]public async Task AServiceMutationPendingOrderForeignMarketOrNonFreshnessFailureNeverRetriesTheRead()
    {
        foreach(var scenario in new[]{"sent","pending","wrong-kind","wrong-market","other-error","missing-proof"})
        {
            using var directory=new TemporaryDirectory();var channel=new Channel();
            channel.ServiceReplyFactory=(_,id)=>scenario switch
            {"sent"=>ServiceReadFailure(id,mutations:1),"pending"=>ServiceReadFailure(id,reconciliation:true),"wrong-kind"=>ServiceReadFailure(id,kind:4),
                "wrong-market"=>ServiceReadFailure(id,market:2),"other-error"=>ServiceReadFailure(id,error:2),_=>JsonSerializer.SerializeToElement(new{state=4,generation=id,error=1})};
            var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);var refused=false;
            try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
            Check.That(refused);Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
        }
    }
    [Test]public async Task SessionDockingChangesAndUserCancellationCannotStartASecondServiceRead()
    {
        foreach(var scenario in new[]{"commander","ship","session","docking","cancel"})
        {
            using var directory=new TemporaryDirectory();using var stop=new CancellationTokenSource();var channel=new Channel();
            channel.ServiceReplyFactory=(_,id)=>
            {
                if(scenario=="cancel")stop.Cancel();
                else channel.Player=scenario switch{"commander"=>channel.Player with{CommanderId="other"},"ship"=>channel.Player with{CurrentShipId=8},
                    "session"=>channel.Player with{LoadGameAt=DateTimeOffset.UtcNow},_=>channel.Player with{Docked=false}};
                return ServiceReadFailure(id);
            };
            var driver=Driver(channel,directory.Path);await driver.PreflightAsync(default);var stopped=false;
            try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,stop.Token);}
            catch(Exception error)when(error is InvalidOperationException or OperationCanceledException){stopped=true;}
            Check.That(stopped);Check.Equal(1,channel.ServiceReads);Check.Equal(0,channel.Mutations);
        }
    }
    [Test] public async Task ConcurrentMaintenanceDoesNotChargeTheSameQuotedServiceTwice()
    {
        var channel=new Channel();var directory=Path.Combine(Path.GetTempPath(),"xfe-services-concurrent-"+Guid.NewGuid().ToString("N"));
        var one=Driver(channel,directory);var two=Driver(channel,directory);await one.PreflightAsync(default);await two.PreflightAsync(default);
        await Task.WhenAll(one.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default),two.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default));
        Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);Check.Equal(3,channel.ServiceReads);
    }
    [Test]public async Task FlightRefuellingOnlyPaysFuelAndKeepsItsConfirmedReceiptWithoutRepairingOrRearming()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{RepairNeeded=50001,IntegrityNeeded=20,RearmNeeded=15};
        var driver=Driver(channel,directory.Path);var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;
        await driver.PreflightAsync(default);await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);
        Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);Check.That(!channel.FuelNeeded);
        Check.Equal(50001L,channel.RepairNeeded);Check.Equal(20L,channel.IntegrityNeeded);Check.Equal(15L,channel.RearmNeeded);
        Check.Equal(1,receipts.Count);Check.Equal("refuel",receipts[0].Action);Check.Equal(1,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
        await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);Check.Equal(1,channel.Mutations);
    }
    [Test]public async Task FlightRefuellingCannotSpendReservedFundsOrPublishAnUnrelatedServiceAsFuel()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true};
        var driver=Driver(channel,directory.Path,new(16,9998));await driver.PreflightAsync(default);var rejected=false;
        try{await driver.RefuelDockedAsync(new(1,16),_=>Task.CompletedTask,default);}catch(InvalidOperationException){rejected=true;}
        Check.That(rejected);Check.Equal(0,channel.Mutations);Check.Equal(10000L,channel.Credits);
        var observed=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=observed.Add;
        Check.Throws<InvalidDataException>(()=>driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"repair",1,10000,9995,DateTimeOffset.UtcNow)));
        Check.Throws<InvalidDataException>(()=>driver.ObserveJourneyService(new("invalid","refuel",1,10000,9995,DateTimeOffset.UtcNow)));
        Check.Equal(0,observed.Count);
        driver.ObserveJourneyService(new(Guid.NewGuid().ToString("N"),"refuel",1,10000,9995,DateTimeOffset.UtcNow));Check.Equal(1,observed.Count);
    }
    [Test] public async Task OutstandingFinalServiceBlocksDepartureButKeepsItsConfirmedExpense()
    {
        var channel=new Channel{KeepServiceOutstanding=true};var directory=Path.Combine(Path.GetTempPath(),"xfe-services-incomplete-"+Guid.NewGuid().ToString("N"));
        var driver=Driver(channel,directory);var receipts=new List<ConfirmedDockedServiceReceipt>();driver.ServiceConfirmed+=receipts.Add;await driver.PreflightAsync(default);
        var refused=false;try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
        Check.That(refused);Check.Equal(1,channel.Mutations);Check.Equal(1,receipts.Count);Check.Equal(5m,receipts[0].ToCashEntry().Amount);
        Check.Equal(2,channel.ServiceReads);Check.Equal(1,Directory.GetFiles(directory,"*.confirmed.json").Length);
    }
    [Test] public async Task InvalidBatchQuoteRefusesEveryServiceBeforeAnyDebit()
    {
        var channel=new Channel{RepairNeeded=50001};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-limit-"+Guid.NewGuid().ToString("N")));
        await driver.PreflightAsync(default);var refused=false;try{await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(10000L,channel.Credits);
    }
    [Test] public async Task OlderPluginRefreshesRequiredQuoteBeforeEachDebit()
    {
        var channel=new Channel{QuoteSnapshotsSupported=false,RepairNeeded=12};var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-services-legacy-"+Guid.NewGuid().ToString("N")));
        await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
        Check.Equal(2,channel.Mutations);Check.Equal(9983L,channel.Credits);Check.Equal(4,channel.ServiceReads);
    }
    private static NativeTradeCommandDriver JourneyDriver(Channel channel,string directory)
    {
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,directory,async(destination,check,token)=>
        {
            await check(token);channel.ActualMarket=(ulong)destination.MarketId;channel.Player=channel.Player with{MarketId=destination.MarketId};
        },_=>Task.CompletedTask);
        driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
    }
    [Test] public async Task ProductionJourneyReconcilesServicesAndNeverReplaysACompletedBatch()
    {
        var channel=new Channel();var directory=Path.Combine(Path.GetTempPath(),"xfe-production-journey-"+Guid.NewGuid().ToString("N"));
        var driver=JourneyDriver(channel,Path.Combine(directory,"operations"));var journey=new NativeTradeJourney(driver,()=>channel.Player,_=>Task.CompletedTask,directory);
        var result=await journey.RunAsync(Buy().Leg,default);
        Check.That(result.Passed);Check.Equal(7m,result.Profit.NetProfit);Check.Equal(10007L,result.ClosingCredits);Check.Equal(3,channel.Mutations);
        var completed=await journey.RunAsync(Buy().Leg,default);Check.That(completed.Passed);Check.Equal(3,channel.Mutations);Check.Equal(0,channel.Cargo);
    }
    [Test] public async Task AppliedPurchaseSurvivesFollowupFailureAndResumesWithoutAnotherBuy()
    {
        var channel=new Channel{FailPostTradeReadOnce=true};var directory=Path.Combine(Path.GetTempPath(),"xfe-production-resume-"+Guid.NewGuid().ToString("N"));
        var driver=JourneyDriver(channel,Path.Combine(directory,"operations"));var journey=new NativeTradeJourney(driver,()=>channel.Player,_=>Task.CompletedTask,directory);
        var failed=false;try{await journey.RunAsync(Buy().Leg,default);}catch(InvalidOperationException){failed=true;}
        Check.That(failed);var checkpoint=TradeJourneyCheckpoints.Load(Path.Combine(directory,"journey.json"));Check.That(checkpoint.Bought is not null&&checkpoint.Sold is null);
        var resumed=await new NativeTradeJourney(JourneyDriver(channel,Path.Combine(directory,"operations")),()=>channel.Player,_=>Task.CompletedTask,directory).RunAsync(Buy().Leg,default);
        Check.That(resumed.Passed);Check.Equal(3,channel.Mutations);Check.Equal(7m,resumed.Profit.NetProfit);Check.Equal(0,channel.Cargo);
        Check.Equal(2,Directory.GetFiles(Path.Combine(directory,"attempts"),"*.json").Length);
    }
    [Test] public void VerifiedNativeWalletCannotOverrideAnotherSessionOrNewerJournalBalance()
    {
        var channel=new Channel();var at=DateTimeOffset.UtcNow;var observation=new NativeWalletObservation(channel.Player.CommanderId,7,channel.Player.LoadGameAt!.Value,9000,at);
        Check.Equal(9000m,observation.Project(channel.Player).Credits);
        var newer=channel.Player with{Credits=11000,CreditsAt=at.AddSeconds(1)};Check.That(ReferenceEquals(newer,observation.Project(newer)));
        var other=channel.Player with{LoadGameAt=channel.Player.LoadGameAt!.Value.AddMinutes(1)};Check.That(ReferenceEquals(other,observation.Project(other)));
    }
    [Test] public async Task JournalDockedWaitsForNativeDockingBeforeReadingOrTrading()
    {
        var channel=new Channel{DockingPendingReads=2,FuelNeeded=false};
        var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-readiness-"+Guid.NewGuid().ToString("N"));
        var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
        Check.That(channel.ReadinessProbes>=3);Check.Equal(2,channel.MarketReads);Check.Equal(1,channel.Mutations);
    }
    [Test] public async Task DifferentNativeMarketRefusesAllMarketAndMonetaryRequests()
    {
        var channel=new Channel{ActualMarket=2};var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-market-"+Guid.NewGuid().ToString("N"));
        var driver=Driver(channel,directory);await driver.PreflightAsync(default);var refused=false;
        try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.MarketReads);Check.Equal(0,channel.Mutations);
    }
    [Test] public async Task CancellingNativeDockingWaitSubmitsNoOrder()
    {
        using var stop=new CancellationTokenSource();var channel=new Channel{DockingPendingReads=int.MaxValue,OnReadinessProbe=stop.Cancel};
        var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-cancel-"+Guid.NewGuid().ToString("N"));var driver=Driver(channel,directory);await driver.PreflightAsync(default);var cancelled=false;
        try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,stop.Token);}catch(OperationCanceledException){cancelled=true;}
        Check.That(cancelled);Check.Equal(0,channel.MarketReads);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory,"*.pending.json").Length);
    }
    [Test] public async Task MaintenanceWaitsForNativeDockingBeforeQuotingAndCharging()
    {
        var channel=new Channel{DockingPendingReads=2};var directory=Path.Combine(Path.GetTempPath(),"xfe-docking-maintenance-"+Guid.NewGuid().ToString("N"));
        var driver=Driver(channel,directory);await driver.PreflightAsync(default);await driver.ServiceDockedAsync(new(1,1),_=>Task.CompletedTask,default);
        Check.That(channel.ReadinessProbes>=3);Check.That(channel.ServiceReads>0);Check.Equal(1,channel.Mutations);Check.Equal(9995L,channel.Credits);
    }
    private static Channel CgChannel()
    {
        var channel=new Channel{Credits=25000000,Commodity="uranium",CommodityId=128049172,UnitBuy=6000,UnitSell=57104,BatchSupported=true,FuelNeeded=false};
        channel.Player=channel.Player with{Credits=25000000,CargoCapacity=192};return channel;
    }
    private static TradeLeg CgLeg(int quantity=192)
        =>new(){From=Check.Station(1),To=Check.Station(4210924803),Orders=[new("uranium","Uranium",quantity,6000,57104)]};
    [Test] public async Task ExplicitCgBatchesReconcileFullQuantityCostAndActualSaleProceeds()
    {
        var channel=CgChannel();var directory=Path.Combine(Path.GetTempPath(),"xfe-cg-batch-"+Guid.NewGuid().ToString("N"));
        var driver=Driver(channel,directory,new(192,1000000));await driver.PreflightAsync(default);
        var leg=CgLeg();var buy=new TradeAction(TradeActionKind.Buy,leg,leg.Orders[0]);
        var bought=await driver.ExecuteTradeAsync(buy,_=>Task.CompletedTask,default);bought.Validate(buy,channel.Player,channel.Player.LoadGameAt!.Value);
        Check.Equal(23848000L,bought.CreditsAfter);Check.Equal(192,bought.QuantityDelta);Check.Equal(192,channel.Cargo);
        channel.ActualMarket=4210924803;channel.Player=channel.Player with{MarketId=4210924803};
        var sell=new TradeAction(TradeActionKind.Sell,leg,leg.Orders[0]);var sold=await driver.ExecuteTradeAsync(sell,_=>Task.CompletedTask,default);sold.Validate(sell,channel.Player,channel.Player.LoadGameAt!.Value);
        Check.Equal(34811968L,sold.CreditsAfter);Check.Equal(-192,sold.QuantityDelta);Check.Equal(0,channel.Cargo);Check.Equal(2,channel.Mutations);
    }
    [Test] public async Task DefaultAcceptanceDriverStillRefusesCgBatches()
    {
        var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-default-"+Guid.NewGuid().ToString("N")));await driver.PreflightAsync(default);
        var leg=CgLeg();var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);
    }
    [Test] public async Task CgBatchCannotSpendTheReservedWalletBalance()
    {
        var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-reserve-"+Guid.NewGuid().ToString("N")),new(192,24000000));await driver.PreflightAsync(default);
        var leg=CgLeg();var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);
    }
    [Test] public async Task CgBatchRefusesAQuantityAboveTheVerifiedHold()
    {
        var channel=CgChannel();var driver=Driver(channel,Path.Combine(Path.GetTempPath(),"xfe-cg-capacity-"+Guid.NewGuid().ToString("N")),new(192,1000000));await driver.PreflightAsync(default);
        var leg=CgLeg(193);var refused=false;try{await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,leg.Orders[0]),_=>Task.CompletedTask,default);}catch(InvalidOperationException){refused=true;}
        Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);
    }
    [Test] public async Task FormalTradeUsesTheEntireCurrentShipAtArbitraryDestination()
    {
        using var directory=new TemporaryDirectory();
        foreach(var size in new[]{1,192,752,2048})
        {
            var channel=new Channel{Credits=10000000,Commodity="gold",CommodityId=123456,BatchSupported=true,FuelNeeded=false};
            channel.Player=channel.Player with{Ship="type9",CargoCapacity=size,Credits=channel.Credits};
            var order=new TradeOrder("gold","Gold",size,68,80);var leg=new TradeLeg{From=Check.Station(1),To=Check.Station(99),Orders=[order]};
            var driver=Driver(channel,Path.Combine(directory.Path,size.ToString()),new(size,1000));await driver.PreflightAsync(default);
            var bought=await driver.ExecuteTradeAsync(new(TradeActionKind.Buy,leg,order),_=>Task.CompletedTask,default);
            Check.Equal(size,bought.QuantityDelta);channel.Player=channel.Player with{MarketId=99};channel.ActualMarket=99;
            var sold=await driver.ExecuteTradeAsync(new(TradeActionKind.Sell,leg,order),_=>Task.CompletedTask,default);
            Check.Equal(-size,sold.QuantityDelta);Check.Equal(0,channel.Cargo);Check.Equal(10000000L+12*size,channel.Credits);
        }
    }
    [Test] public async Task OrdinaryEngineRepeatsBothDirectionsWithoutAnyCgCalls()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
        channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
        var navigationCalls=0;
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
            (station,_,_)=>{if(++navigationCalls==1)throw new InvalidOperationException("Stop flight control before selecting a local destination");channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;},_=>Task.CompletedTask,batchPolicy:new(752,1000));
        driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
        var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
        var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=2};
        var cycles=0;engine.Progress+=p=>{if(p.Phase=="cycle-completed")cycles++;};
        await engine.RunAsync(default);Check.That(navigationCalls>=3);Check.Equal(2,cycles);Check.Equal(4,channel.Mutations);Check.Equal(0,channel.Cargo);Check.Equal(1L,channel.Player.MarketId!.Value);
    }
    [Test] public async Task DepartureQueueRecoveryContinuesEmptyOrLoadedWithoutRepeatingOrders()
    {
        foreach(var loaded in new[]{false,true})
        {
            using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
            channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
            var failed=false;var recovered=0;
            var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
                (station,_,_)=>
                {
                    if(!failed&&(channel.Cargo>0)==loaded)
                    {failed=true;channel.Player=channel.Player with{Docked=false};throw new StationDepartureRecoveryException((long)channel.ActualMarket,TimeSpan.FromMinutes(3));}
                    channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
                },_=>Task.CompletedTask,batchPolicy:new(752,1000));
            driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
            var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
            Task Recover(CancellationToken _)
            {recovered++;channel.Player=channel.Player with{Docked=true,LoadGameAt=channel.Player.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;}
            var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,Recover){MaximumCycles=1};
            await engine.RunAsync(default);
            Check.That(failed);Check.Equal(1,recovered);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
        }
    }
    [Test] public async Task HealthTelemetryRefusalAfterPurchaseWaitsAndContinuesTheOriginalCargo()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
        channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
        var refusals=0;
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
            (station,_,_)=>
            {
                if(channel.Cargo>0&&refusals++<2)throw new InvalidOperationException("Native main-ship health unavailable or stale");
                channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
            },_=>Task.CompletedTask,batchPolicy:new(752,1000));
        driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
        var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
        var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=1};
        await engine.RunAsync(default);
        Check.That(refusals>=3);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
        Check.Equal(1,Directory.GetFiles(directory.Path,"settled.json",SearchOption.AllDirectories).Length);
    }
    [Test] public async Task TransientFlightReadsDoNotSpendEmptyOrLoadedSessionRecoveryAttempts()
    {
        foreach(var loaded in new[]{false,true})foreach(var departure in new[]{false,true})
        {
            using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false};
            channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
            var reads=0;var recovered=0;
            var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
                (station,_,_)=>
                {
                    if((channel.Cargo>0)==loaded&&++reads<=5)
                    {
                        if(reads<=4)throw new FlightTelemetryUnavailableException("fixture transient during scene transition");
                        channel.Player=channel.Player with{Docked=false};
                        if(departure)throw new StationDepartureRecoveryException((long)channel.ActualMarket,TimeSpan.FromMinutes(3));
                        throw new DockingHandoffException("fixture landing handoff",station.MarketId);
                    }
                    channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;
                },_=>Task.CompletedTask,batchPolicy:new(752,1000));
            driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
            var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path};
            var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,_=>
            {recovered++;channel.Player=channel.Player with{Docked=true,LoadGameAt=channel.Player.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;}){MaximumCycles=1};
            await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(8));
            Check.Equal(1,recovered);Check.That(reads>=6);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
            Check.Equal(1,Directory.GetFiles(directory.Path,"settled.json",SearchOption.AllDirectories).Length);
        }
    }
    [Test] public async Task CgEngineUsesUserDestinationAndConfirmsItsActualContribution()
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false,Goals=true};
        channel.Player=channel.Player with{CargoCapacity=752,Credits=channel.Credits,Ship="type9",Rebuy=1000};
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,Path.Combine(directory.Path,"operations"),
            (station,_,_)=>{channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;return Task.CompletedTask;},_=>Task.CompletedTask,batchPolicy:new(752,1000));
        driver.WalletObserved+=o=>channel.Player=o.Project(channel.Player);
        var configuration=new AutomaticTradeConfiguration{Source=new(){MarketId=1,Name="One",SystemName="A",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=99,Name="Other",SystemName="B",MaxLandingPad=LandingPad.Large},WorkingDirectory=directory.Path,
            Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=42,Commodities=["biowaste"],ReturnTrade=false};
        var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path){MaximumCycles=1};
        await engine.RunAsync(default);Check.Equal(752L,channel.Contribution);Check.That(channel.CgReads>=2);Check.Equal(2,channel.Mutations);Check.Equal(0,channel.Cargo);
        Check.Equal(1,Directory.GetFiles(directory.Path,"cg-contribution-confirmed.json",SearchOption.AllDirectories).Length);
    }
    [Test]public Task DynamicCgSupplyExcludesAKnownFineWithoutChangingTheUserConfigurationOrPayingIt()=>VerifyDynamicFineSelection(false);
    [Test]public Task AFineNewlyConfirmedOnArrivalChangesSourceBeforeReadingItsMarketOrBuying()=>VerifyDynamicFineSelection(true);
    [Test]public Task DynamicSelectionCannotIgnoreTheConfirmedFineExclusion()=>VerifyDynamicFineSelection(false,ignoreExclusion:true);
    [Test]public Task AnUnconfirmedOriginalOperationPreventsFineBasedSourceReplacement()=>VerifyDynamicFineSelection(false,unconfirmed:true);
    private static async Task VerifyDynamicFineSelection(bool fineOnArrival,bool ignoreExclusion=false,bool unconfirmed=false)
    {
        using var directory=new TemporaryDirectory();var channel=new Channel{Credits=10000000,BatchSupported=true,FuelNeeded=false,Goals=true};
        channel.Player=channel.Player with{CargoCapacity=16,Credits=channel.Credits,Ship="type9",Rebuy=1000};
        var journal=directory.File("Journal.dynamic-fine.log");var operations=Path.Combine(directory.Path,"operations");
        Task AddFine()=>File.WriteAllLinesAsync(journal,new object[]
        {
            new{@event="LoadGame",FID=channel.Player.CommanderId},new{@event="Docked",MarketID=1,StationFaction=new{Name="Bowen authority"}},
            new{@event="CommitCrime",Faction="Bowen authority",Fine=400}
        }.Select(value=>JsonSerializer.Serialize(value)));
        if(!fineOnArrival)await AddFine();
        var visited=new List<long>();var selections=0;var restrictedArrival=false;
        var driver=new NativeTradeCommandDriver(channel,()=>channel.Player,operations,async(station,_,token)=>
        {
            visited.Add(station.MarketId);channel.Player=channel.Player with{MarketId=station.MarketId,Docked=true};channel.ActualMarket=(ulong)station.MarketId;
            if(fineOnArrival&&station.MarketId==1)await AddFine();
            if(unconfirmed&&station.MarketId==99)await File.WriteAllTextAsync(Path.Combine(operations,"still-unknown.pending.json"),"{}",token);
        },_=>Task.CompletedTask,batchPolicy:new(16,1000)){JournalDirectory=directory.Path};
        driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);
        var originalSource=Check.Station(1);var destination=Check.Station(99);var configuration=new AutomaticTradeConfiguration
        {Source=originalSource,Destination=destination,WorkingDirectory=directory.Path,Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=42,Commodities=["biowaste"],ReturnTrade=false};
        Task<AutomaticSupplyChoice?> Select(AutomaticSupplyRequest request,CancellationToken token)
        {
            token.ThrowIfCancellationRequested();selections++;
            Check.Equal(channel.Player.CommanderId,request.Player.CommanderId);Check.Equal(99L,request.Configuration.Destination.MarketId);
            var market=request.ExcludedSourceMarkets.Contains(1)&&!ignoreExclusion?2L:1L;
            if(!fineOnArrival||selections>1)Check.That(request.ExcludedSourceMarkets.Contains(1));
            var source=Check.Station(market);var order=new TradeOrder("biowaste","Biowaste",16,68,80);
            var delivery=new TradeLeg{From=source,To=destination,Orders=[order],EstimatedMinutes=5};
            return Task.FromResult<AutomaticSupplyChoice?>(new(source,delivery,100000,100000,16,5,0,192,10));
        }
        var engine=new AutomaticTradeEngine(configuration,driver,channel,()=>channel.Player,_=>Task.CompletedTask,directory.Path,selectSource:Select){MaximumCycles=1};
        engine.Progress+=progress=>restrictedArrival|=progress.Phase=="source-access-restricted";
        Exception? failure=null;using var budget=new CancellationTokenSource(TimeSpan.FromSeconds(8));
        try{await engine.RunAsync(budget.Token);}catch(Exception error){failure=error;}
        Check.That(!budget.IsCancellationRequested,"The engine must finish or reject the exact source proof, not wait indefinitely on its fine.");
        if(ignoreExclusion||unconfirmed)
        {
            Check.That(failure is not null);Check.Equal(0,channel.Mutations);
            if(ignoreExclusion){Check.That(failure is InvalidDataException);Check.Equal(1,selections);}
            if(unconfirmed){Check.Equal(0,selections);Check.Equal("{}",await File.ReadAllTextAsync(Path.Combine(operations,"still-unknown.pending.json")));}
        }
        else
        {
            if(failure is not null)throw failure;
            Check.That(selections>=2);Check.That(visited.Contains(2));Check.Equal(fineOnArrival,restrictedArrival);
            Check.That(!channel.MarketReadMarkets.Contains(1));Check.Equal(2,channel.Mutations);Check.Equal(16L,channel.Contribution);Check.Equal(0,channel.Cargo);
            Check.Equal(1,Directory.GetFiles(directory.Path,"cg-contribution-confirmed.json",SearchOption.AllDirectories).Length);
        }
        Check.Equal(1L,configuration.Source.MarketId);Check.Equal(99L,configuration.Destination.MarketId);
        Check.Equal(400L,await StationAccessWaiter.KnownFineAsync(directory.Path,channel.Player.CommanderId,1,default));
    }
    [Test] public void CgBatchRequiresAnActualWalletEvenIfTheReliabilityFlagWasSet()
    {
        var channel=CgChannel();var leg=CgLeg();var policy=new NativeTradeBatchPolicy(192,1000000);
        Check.Throws<InvalidOperationException>(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=null,CreditsReliable=true}));
        Check.Throws<InvalidOperationException>(()=>policy.ValidateAction(new(TradeActionKind.Buy,leg,leg.Orders[0]),channel.Player with{Credits=-1,CreditsReliable=true}));
    }
}