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<TradeAction, Task>? OnAction;
public List<DockedServiceRequest> Services=[];
public Func<DockedServiceRequest,CancellationToken,Task>? OnServices;
public event Action<ConfirmedDockedServiceReceipt>? 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<CancellationToken,Task> 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<CancellationToken, Task> 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(() => 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<RunProgress>();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);
}
}
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<TradeAction, Task>? OnAction;
public List<DockedServiceRequest> Services=[];
public Func<DockedServiceRequest,CancellationToken,Task>? OnServices;
public event Action<ConfirmedDockedServiceReceipt>? 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<CancellationToken,Task> 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<CancellationToken, Task> 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(() => 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<RunProgress>();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);
}
}