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 XFEExtension.NetCore.XUnit.Attributes;

namespace XFEEDToolBox.Test;

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