using XFEExtension.NetCore.XUnit.Attributes; using XFEEDToolBox.Core.Data; using XFEEDToolBox.Core.Execution; using XFEEDToolBox.Core.Models; namespace XFEEDToolBox.Test; [TestFixture] public class ExecutionTests { private static RoutePlan Plan => new() { Request = Check.Request with { MaxLegs = 1 }, Legs = [new() { Number = 1, From = Check.Station(1), To = Check.Station(2, 10), Orders = [new("gold", "Gold", 3, 100, 180)], EstimatedMinutes = 5 }] }; private static PlayerState Player => new() { Ship = "Type9", InGame = true, Docked = true, MarketId = 1, Credits = 10000, CreditsReliable = true, CargoCapacity = 10, StatusAt = DateTimeOffset.UtcNow }; private static CommodityQuote Quote(long station, string key) => Check.Quote(station, key, station == 1 ? 100 : 200, station == 1 ? 90 : 180); [Test]public async Task LiveLargeHullRejectsAStaleSmallShipPlanBeforeAnyNativeDriverCall() { var driver=new Driver();var runner=new TradeRunner(()=>Player,Quote);var original=Plan; var unsafePlan=original with{Request=original.Request with{RequiredPad=LandingPad.Small,ActualShipType="SideWinder"}, Legs=[original.Legs[0] with{To=original.Legs[0].To with{MaxLandingPad=LandingPad.Medium}}]}; await runner.RunAsync(unsafePlan,ExecutionMode.NativeBridge,driver); Check.Equal(RunState.Failed,runner.State);Check.Equal(0,driver.Calls); } private sealed class Driver : ITradeCommandDriver,IConfirmedDockedServiceDriver,ITradeFlightSafetyScope { public bool FlightSafetyActive { get; set; } public int Calls; public bool Released; public Func? OnAction; public List Services=[]; public Func? OnServices; public event Action? ServiceConfirmed; public void ConfirmService(long market,string action,long amount) => ServiceConfirmed?.Invoke(new(Guid.NewGuid().ToString("N"),action,market,10000,10000-amount,DateTimeOffset.UtcNow)); public Task PreflightAsync(CancellationToken token) { Calls++; return Task.CompletedTask; } public async Task ServiceDockedAsync(DockedServiceRequest request,Func beforeInput,CancellationToken token) {await beforeInput(token);Services.Add(request);if(OnServices is not null)await OnServices(request,token);} public async Task ExecuteAsync(TradeAction action, Func beforeInput, CancellationToken token) { await beforeInput(token); Calls++; if (OnAction != null) await OnAction(action); } public Task ReleaseAllAsync() { Released = true; return Task.CompletedTask; } } [Test] public async Task SimulationDoesNotSendGameInput() { var driver = new Driver(); var runner = new TradeRunner(() => Player with { InGame = false, InDanger = true }, Quote); await runner.RunAsync(Plan, ExecutionMode.Simulation, driver); Check.Equal(0, driver.Calls); Check.Equal(RunState.Completed, runner.State); Check.That(driver.Released); } [Test]public async Task NativeFlightOwnsGenericHeatWarningsOnlyWhileItsSafetyScopeIsActive() { var driver=new Driver {FlightSafetyActive=true};var player=Player;var observed=false; var runner=new TradeRunner(()=>player,Quote); runner.Progress+=progress=> { if(observed||!progress.Message.StartsWith("开始执行"))return; observed=true;player=player with{InDanger=true};runner.ObservePlayer(player); Check.Equal(RunState.Running,runner.State); driver.FlightSafetyActive=false;runner.ObservePlayer(player);Check.Equal(RunState.Paused,runner.State); driver.FlightSafetyActive=true;runner.Resume();Check.Equal(RunState.Running,runner.State); player=player with{Navigation=player.Navigation with{ExternalThreat=true}}; runner.ObservePlayer(player);Check.Equal(RunState.Paused,runner.State); Check.Throws(runner.Resume);runner.Stop(); }; await runner.RunAsync(Plan,ExecutionMode.NativeBridge,driver); Check.That(observed);Check.Equal(RunState.Stopped,runner.State);Check.That(driver.Released); } [Test]public async Task JournalGuidanceCannotDelegateItsDangerGuardToADriver() { var driver=new Driver {FlightSafetyActive=true};var player=Player;var observed=false; var runner=new TradeRunner(()=>player,Quote); runner.Progress+=progress=> { if(observed||!progress.Message.StartsWith("开始执行"))return; observed=true;player=player with{InDanger=true};runner.ObservePlayer(player); Check.Equal(RunState.Paused,runner.State);Check.Throws(runner.Resume);runner.Stop(); }; await runner.RunAsync(Plan,ExecutionMode.JournalGuide,driver); Check.That(observed);Check.Equal(RunState.Stopped,runner.State);Check.Equal(0,driver.Calls); } [Test] public async Task MacroModeRequiresCorrectStartingStation() { var driver = new Driver(); var runner = new TradeRunner(() => Player with { MarketId = 99 }, Quote); await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(0, driver.Calls); Check.Equal(RunState.Failed, runner.State); } [Test] public async Task MacroModeRequiresReliableCredits() { var driver = new Driver(); var runner = new TradeRunner(() => Player with { CreditsReliable = false }, Quote); await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(0, driver.Calls); } [Test] public async Task MissingCargoSpaceBlocksExecution() { var driver = new Driver(); var runner = new TradeRunner(() => Player with { CargoCount = 8 }, Quote); await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(0, driver.Calls); } private static PlayerState OccupiedPlayer=>Player with{CommanderId="occupied-test",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1), CargoReliable=true,CargoCount=2,Inventory=[new("gold","Gold",2)]}; [Test]public async Task NativeOccupiedPlanKeepsTheEmptyCargoAdmissionGuardBeforeAnyDriverCall() { var driver=new Driver();var runner=new TradeRunner(()=>OccupiedPlayer,Quote);RunProgress? last=null;runner.Progress+=value=>last=value; await runner.RunAsync(Plan with{Request=Plan.Request with{OccupiedCargo=2}},ExecutionMode.NativeBridge,driver); Check.Equal(RunState.Failed,runner.State);Check.Equal(0,driver.Calls);Check.That(last!.Message.Contains("空货舱")); } [Test]public async Task OccupiedJournalPlanUsesFreeCapacityAndLeavesTheOriginalSameCommodityUntouched() { var player=OccupiedPlayer;var driver=new Driver();var runner=new TradeRunner(()=>player,Quote);long sequence=0; void Emit(object value)=>runner.Observe(new(++sequence,DateTimeOffset.UtcNow,Check.Event(value))); runner.Progress+=value=> { if(value.Message.StartsWith("买入 ")&&value.Message.Contains("等待实际交易确认")) {player=player with{Credits=9700,CargoCount=5,Inventory=[new("gold","Gold",5)]};Emit(new{@event="MarketBuy",MarketID=1,Type="gold",Count=3,TotalCost=300});} else if(value.Message.StartsWith("前往 ")) {player=player with{MarketId=2};Emit(new{@event="Docked",MarketID=2});} else if(value.Message.StartsWith("卖出 ")&&value.Message.Contains("等待实际交易确认")) {player=player with{Credits=10240,CargoCount=2,Inventory=[new("gold","Gold",2)]};Emit(new{@event="MarketSell",MarketID=2,Type="gold",Count=3,TotalSale=540});} }; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2)); await runner.RunAsync(Plan with{Request=Plan.Request with{OccupiedCargo=2}},ExecutionMode.JournalGuide,driver,timeout.Token); Check.Equal(RunState.Completed,runner.State);Check.Equal(0,driver.Calls);Check.Equal(2,player.CargoCount);Check.Equal(2,player.Inventory.Single().Count); } [Test]public async Task UnknownOccupiedInventoryAndChangedOccupiedCountCannotStartGuidance() { foreach(var player in new[]{OccupiedPlayer with{Inventory=[]},OccupiedPlayer with{CargoCount=3,Inventory=[new("gold","Gold",3)]},OccupiedPlayer with{CurrentShipId=null}}) { var driver=new Driver();var runner=new TradeRunner(()=>player,Quote); await runner.RunAsync(Plan with{Request=Plan.Request with{OccupiedCargo=2}},ExecutionMode.JournalGuide,driver); Check.Equal(RunState.Failed,runner.State);Check.Equal(0,driver.Calls); } } [Test]public async Task MissingNewCargoCannotBeReplacedByThePreservedSameCommodityAtSale() { var player=OccupiedPlayer;var runner=new TradeRunner(()=>player,Quote);long sequence=0;var saleRequested=false; void Emit(object value)=>runner.Observe(new(++sequence,DateTimeOffset.UtcNow,Check.Event(value))); runner.Progress+=value=> { if(value.Message.StartsWith("买入 ")&&value.Message.Contains("等待实际交易确认")) {player=player with{Credits=9700,CargoCount=5,Inventory=[new("gold","Gold",5)]};Emit(new{@event="MarketBuy",MarketID=1,Type="gold",Count=3,TotalCost=300});} else if(value.Message.StartsWith("前往 ")) {player=player with{MarketId=2,CargoCount=3,Inventory=[new("gold","Gold",3)]};Emit(new{@event="Docked",MarketID=2});} else if(value.Message.StartsWith("卖出 ")&&value.Message.Contains("等待实际交易确认"))saleRequested=true; }; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(2)); await runner.RunAsync(Plan with{Request=Plan.Request with{OccupiedCargo=2}},ExecutionMode.JournalGuide,token:timeout.Token); Check.Equal(RunState.Failed,runner.State);Check.That(!saleRequested);Check.Equal(3,player.CargoCount); } [Test] public void NativeModeRequiresConnectedBridge() { var driver = new XFEEDToolBox.Core.Native.NativeTradeDriver(new XFEEDToolBox.Core.Native.NativeBridgeClient()); Check.Throws(() => driver.PreflightAsync(CancellationToken.None)); } [Test] public async Task CapturedQuoteCannotBeUsedForRealRun() { var driver = new Driver(); var runner = new TradeRunner(() => Player, (id, key) => Quote(id, key) with { Source = DataSource.Capture }); await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(1, driver.Calls); Check.Equal(RunState.Failed, runner.State); } [Test] public async Task ChangedPriceStopsBeforeBuying() { var driver = new Driver(); var runner = new TradeRunner(() => Player, (id, key) => Quote(id, key) with { BuyPrice = 1000 }); await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(1, driver.Calls); Check.Equal(RunState.Failed, runner.State); } [Test] public async Task ConfirmsFullTradeCycleFromJournalEvents() { var p = Player; var driver = new Driver(); var runner = new TradeRunner(() => p, Quote); long seq = 0; void Emit(object e) => runner.Observe(new(++seq, DateTimeOffset.UtcNow, Check.Event(e))); driver.OnAction = action => { switch (action.Kind) { case TradeActionKind.Buy: p = p with { Credits = 9700, CargoCount = 3, Inventory = [new("gold", "Gold", 3)] }; Emit(new { @event = "MarketBuy", MarketID = 1, Type = "gold", Count = 3, TotalCost = 300 }); break; case TradeActionKind.Travel: p = p with { MarketId = 2 }; Emit(new { @event = "Docked", MarketID = 2 }); break; case TradeActionKind.Sell: p = p with { Credits = 10240, CargoCount = 0, Inventory = [] }; Emit(new { @event = "MarketSell", MarketID = 2, Type = "gold", Count = 3, TotalSale = 540 }); break; } return Task.CompletedTask; }; RunProgress? final = null; runner.Progress += x => final = x; await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(RunState.Completed, runner.State); Check.Equal(240m, final!.ActualProfit); Check.Equal(4, driver.Calls); } [Test] public async Task ServicesAndTransactionsRunTogetherButBothMustFinishBeforeDepartureAndCompletion() { var p=Player;var driver=new Driver();var runner=new TradeRunner(()=>p,Quote);long seq=0; var sourceService=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var destinationService=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var bought=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var sold=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var travelled=false; void Emit(object value)=>runner.Observe(new(++seq,DateTimeOffset.UtcNow,Check.Event(value))); driver.OnServices=(request,token)=>(request.MarketId==1?sourceService.Task:destinationService.Task).WaitAsync(token); driver.OnAction=action=> { if(action.Kind==TradeActionKind.Buy) { Check.That(!sourceService.Task.IsCompleted); // purchase does not wait for maintenance p=p with{CargoCount=3,Inventory=[new("gold","Gold",3)]}; Emit(new{@event="MarketBuy",MarketID=1,Type="gold",Count=3,TotalCost=300});bought.SetResult(); } else if(action.Kind==TradeActionKind.Travel) { Check.That(sourceService.Task.IsCompletedSuccessfully);travelled=true;p=p with{MarketId=2}; Emit(new{@event="Docked",MarketID=2}); } else { Check.That(!destinationService.Task.IsCompleted); p=p with{CargoCount=0,Inventory=[]}; Emit(new{@event="MarketSell",MarketID=2,Type="gold",Count=3,TotalSale=540});sold.SetResult(); } return Task.CompletedTask; }; var run=runner.RunAsync(Plan,ExecutionMode.NativeBridge,driver); await bought.Task.WaitAsync(TimeSpan.FromSeconds(2));Check.That(!travelled&&!run.IsCompleted); sourceService.SetResult(); await sold.Task.WaitAsync(TimeSpan.FromSeconds(2));Check.That(!run.IsCompleted); destinationService.SetResult();await run.WaitAsync(TimeSpan.FromSeconds(2)); Check.Equal(RunState.Completed,runner.State);Check.Equal(2,driver.Services.Count); Check.That(driver.Services.All(x=>x.ReservedCargo==Plan.Request.CargoCapacity)); } [Test] public async Task ConfirmedServicesReduceLiveNetProfitAndTheirJournalCopiesAreNotCountedTwice() { var p=Player;var driver=new Driver();var runner=new TradeRunner(()=>p,Quote);long seq=0; var progress=new List();runner.Progress+=progress.Add; void Emit(object e)=>runner.Observe(new(++seq,DateTimeOffset.UtcNow,Check.Event(e))); driver.OnServices=(request,_)=> { var amount=request.MarketId==1?5:7; driver.ConfirmService(request.MarketId,request.MarketId==1?"refuel":"repair",amount); Emit(new{@event="RepairAll",MarketID=request.MarketId,Cost=amount}); return Task.CompletedTask; }; driver.OnAction=action=> { if(action.Kind==TradeActionKind.Buy) { p=p with{CargoCount=3,Inventory=[new("gold","Gold",3)]}; Emit(new{@event="MarketBuy",MarketID=1,Type="gold",Count=3,TotalCost=300}); } else if(action.Kind==TradeActionKind.Travel) {p=p with{MarketId=2};Emit(new{@event="Docked",MarketID=2});} else {p=p with{CargoCount=0,Inventory=[]};Emit(new{@event="MarketSell",MarketID=2,Type="gold",Count=3,TotalSale=540});} return Task.CompletedTask; }; await runner.RunAsync(Plan,ExecutionMode.NativeBridge,driver); Check.Equal(RunState.Completed,runner.State); Check.Equal(228m,progress[^1].ActualProfit); Check.Equal(5m,progress[^1].Accounting.Fuel);Check.Equal(7m,progress[^1].Accounting.Repairs); Check.That(progress.Any(x=>x.Accounting.CargoInvestment==300&&x.ActualProfit==-5)); Check.That(progress.Any(x=>x.State==RunState.Running&&x.ActualProfit==228)); } [Test] public async Task ServiceFailureInterruptsAPendingTradeConfirmationAndPreventsDeparture() { var driver=new Driver();var runner=new TradeRunner(()=>Player,Quote); var service=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var bought=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); driver.OnServices=(_,token)=>service.Task.WaitAsync(token); driver.OnAction=action=>{Check.Equal(TradeActionKind.Buy,action.Kind);bought.SetResult();return Task.CompletedTask;}; var run=runner.RunAsync(Plan,ExecutionMode.NativeBridge,driver); await bought.Task.WaitAsync(TimeSpan.FromSeconds(2));service.SetException(new IOException("维修结果尚未确认")); await run.WaitAsync(TimeSpan.FromSeconds(2)); Check.Equal(RunState.Failed,runner.State);Check.Equal(2,driver.Calls);Check.That(driver.Released); } [Test] public async Task UnexpectedTradeStopsAllLaterActions() { var driver = new Driver(); var runner = new TradeRunner(() => Player, Quote); driver.OnAction = _ => { runner.Observe(new(1, DateTimeOffset.UtcNow, Check.Json("""{"event":"MarketBuy","MarketID":1,"Type":"silver","Count":3,"TotalCost":300}"""))); return Task.CompletedTask; }; await runner.RunAsync(Plan, ExecutionMode.NativeBridge, driver); Check.Equal(RunState.Failed, runner.State); Check.Equal(2, driver.Calls); Check.That(driver.Released); } [Test] public async Task StopInterruptsPendingJournalWait() { var runner = new TradeRunner(() => Player, Quote); var task = runner.RunAsync(Plan, ExecutionMode.JournalGuide); await Task.Delay(20); runner.Stop(); await task.WaitAsync(TimeSpan.FromSeconds(1)); Check.Equal(RunState.Stopped, runner.State); } [Test] public async Task InvalidPlanCannotCallDriver() { var driver = new Driver(); var runner = new TradeRunner(() => Player, Quote); var plan = Plan with { Legs = [Plan.Legs[0] with { Orders = [new("gold", "Gold", 100, 100, 180)] }] }; await runner.RunAsync(plan, ExecutionMode.NativeBridge, driver); Check.Equal(0, driver.Calls); } }