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 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);
    }
}