using System.Text.Json;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class AutomaticTradeMenuIntegrationTests
{
private sealed class Commerce:IOriginalCommerceChannel
{
public string Identity{get;}=Guid.NewGuid().ToString("N");
public bool RejectDescribe;public List<string> Calls=[];
public Task<JsonElement> CallAsync(object request,CancellationToken token)
{
token.ThrowIfCancellationRequested();var op=JsonSerializer.SerializeToElement(request).GetProperty("op").GetString()!;Calls.Add(op);
if(op=="describe")
{
if(RejectDescribe)throw new NotSupportedException("fixture interface unavailable");
return Task.FromResult(JsonSerializer.SerializeToElement(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256}));
}
if(op=="commerce.inventory")return Task.FromResult(JsonSerializer.SerializeToElement(new{verified=true,count=0,capacity=16,credits=10000}));
throw new InvalidOperationException("Unexpected operation; this acceptance fixture must never trade: "+op);
}
}
private sealed class Menus:ITradePauseRecoveryChannel
{
public int Renewals,Ends;
public Task PrepareAsync(CancellationToken token)=>Task.CompletedTask;
public Task<JsonElement> ReadAsync(CancellationToken token)=>Task.FromResult(JsonSerializer.SerializeToElement(new
{
state="InGame",nextState="InGame",tradePauseIdentity=new{verified=true,entityId="42",hullFraction=1d,observedAtUnixMilliseconds=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}
}));
public Task RenewAsync(string owner,string run,ulong entity,CancellationToken token){Renewals++;return Task.CompletedTask;}
public Task ResumeAsync(string owner,string run,string request,ulong sequence,CancellationToken token)=>throw new InvalidOperationException("An InGame observation never needs a menu action.");
public Task EndAsync(string owner,string run){Ends++;return Task.CompletedTask;}
}
private sealed class Lease(Func<bool> allowed,TradePauseRecoverySession session):IDisposable
{
public bool Disposed;public bool Allowed=>allowed();
public Task StepAsync()=>session.StepAsync(default);
public void Dispose(){Check.That(!Allowed);Disposed=true;session.EndAsync().GetAwaiter().GetResult();}
}
private static PlayerState Player=>new(){Ship="Type9",InGame=true,CommanderId="engine-menu-fixture",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),
CargoReliable=true,CreditsReliable=true,Credits=10000,CargoCapacity=16,Rebuy=100};
private static AutomaticTradeConfiguration Configuration(string directory)=>new(){WorkingDirectory=directory,
Source=new(){MarketId=1,Name="Source",SystemName="System",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=2,Name="Destination",SystemName="System",MaxLandingPad=LandingPad.Large}};
[Test]public async Task ActualEngineRevokesBeforeRelogAndCreatesANewSessionScopeAfterFastReentry()
{
using var temporary=new TemporaryDirectory();using var cancellation=new CancellationTokenSource(TimeSpan.FromSeconds(5));
var current=Player;var channel=new Commerce();var menus=new Menus();var leases=new List<Lease>();var visits=0;var recoveries=0;var released=0;var preflightObserved=false;
var protectionChecks=0;var recoveryProtectionObserved=false;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),async(_,_,token)=>
{
if(++visits==1)
{
Check.Equal(1,leases.Count);await leases[0].StepAsync();Check.Equal(1,menus.Renewals);
throw new DockingHandoffException("fixture same-mode recovery",2);
}
Check.Equal(2,leases.Count);Check.That(leases[0].Disposed&&!leases[0].Allowed&&leases[1].Allowed);
await leases[1].StepAsync();Check.That(menus.Renewals==2,"The replacement session must bind the new LoadGameAt rather than retaining the old baseline.");
cancellation.Cancel();token.ThrowIfCancellationRequested();
},_=>Task.CompletedTask,()=>{released++;return Task.CompletedTask;})
{
RunningMenuRecoveryFactory=(allowed,_)=>
{
var lease=new Lease(allowed,new TradePauseRecoverySession(menus,()=>current,allowed));leases.Add(lease);return lease;
}
};
Check.Equal(0,leases.Count);
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>
{
protectionChecks++;
if(visits==1&&leases[0].Disposed&&released==1)recoveryProtectionObserved=true;
return Task.CompletedTask;
},temporary.Path,_=>
{
Check.That(leases.Single().Disposed&&!leases.Single().Allowed);Check.Equal(1,menus.Ends);
Check.That(recoveryProtectionObserved&&protectionChecks>=2,"A failed handoff requires released inputs and a fresh healthy context before recovery menu actions.");
current=current with{LoadGameAt=current.LoadGameAt!.Value.AddSeconds(1)};recoveries++;return Task.CompletedTask;
},validateFlight:_=>{Check.Equal(0,leases.Count);return Task.CompletedTask;});
engine.Progress+=progress=>{if(progress.Phase=="preflight"){preflightObserved=true;Check.Equal(0,leases.Count);}};
await engine.RunAsync(cancellation.Token).WaitAsync(TimeSpan.FromSeconds(3));
Check.That(preflightObserved);Check.Equal(1,recoveries);Check.Equal(2,visits);Check.Equal(2,released);
Check.That(leases.All(lease=>lease.Disposed&&!lease.Allowed));Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
[Test]public async Task AnActualProtectiveRestartBreaksSafetyWaitingWithoutAnyFurtherInput()
{
using var temporary=new TemporaryDirectory();var current=Player;var channel=new Commerce();var pending=false;var visits=0;var released=0;var safetyPause=false;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),(_,_,_)=>
{visits++;pending=true;return Task.FromException(new FlightThreatPauseException());},_=>Task.CompletedTask,()=>{released++;return Task.CompletedTask;});
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,sessionRestartPending:()=>pending);
engine.Progress+=progress=>safetyPause|=progress.Phase=="safety-pause";
var restartObserved=false;try{await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(2));}catch(FlightSessionRestartRequiredException){restartObserved=true;}
Check.That(restartObserved&&safetyPause);Check.Equal(1,visits);Check.That(released>=2);
Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
[Test]public async Task ActualSessionRecoveriesRemainBoundedAfterManyTransientReads()
{
using var temporary=new TemporaryDirectory();var current=Player;var channel=new Commerce();var visits=0;var recoveries=0;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),(_,_,_)=>
{
if(++visits%5!=0)throw new FlightTelemetryUnavailableException("fixture fresh-frame retry");
throw new DockingHandoffException("fixture handoff still unavailable",2);
},_=>Task.CompletedTask);
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,_=>
{recoveries++;current=current with{LoadGameAt=current.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;});
var refused=false;try{await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(5));}catch(DockingHandoffException){refused=true;}
Check.That(refused);Check.Equal(3,recoveries);Check.Equal(20,visits);
Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
[Test]public async Task EngineInterfaceAndFlightPreparationFailuresNeverCreateARunningScope()
{
foreach(var rejectDescribe in new[]{true,false})
{
using var temporary=new TemporaryDirectory();var current=Player;var channel=new Commerce{RejectDescribe=rejectDescribe};var scopes=0;var released=0;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),(_,_,_)=>throw new InvalidOperationException("Flight must not start"),_=>Task.CompletedTask,
()=>{released++;return Task.CompletedTask;}){RunningMenuRecoveryFactory=(_,_)=>{scopes++;throw new InvalidOperationException("Scope must not start");}};
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,
validateFlight:_=>throw new NotSupportedException("fixture flight interface unavailable"));
var refused=false;try{await engine.RunAsync(default);}catch(NotSupportedException){refused=true;}
Check.That(refused);Check.Equal(0,scopes);Check.Equal(1,released);Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class AutomaticTradeMenuIntegrationTests
{
private sealed class Commerce:IOriginalCommerceChannel
{
public string Identity{get;}=Guid.NewGuid().ToString("N");
public bool RejectDescribe;public List<string> Calls=[];
public Task<JsonElement> CallAsync(object request,CancellationToken token)
{
token.ThrowIfCancellationRequested();var op=JsonSerializer.SerializeToElement(request).GetProperty("op").GetString()!;Calls.Add(op);
if(op=="describe")
{
if(RejectDescribe)throw new NotSupportedException("fixture interface unavailable");
return Task.FromResult(JsonSerializer.SerializeToElement(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256}));
}
if(op=="commerce.inventory")return Task.FromResult(JsonSerializer.SerializeToElement(new{verified=true,count=0,capacity=16,credits=10000}));
throw new InvalidOperationException("Unexpected operation; this acceptance fixture must never trade: "+op);
}
}
private sealed class Menus:ITradePauseRecoveryChannel
{
public int Renewals,Ends;
public Task PrepareAsync(CancellationToken token)=>Task.CompletedTask;
public Task<JsonElement> ReadAsync(CancellationToken token)=>Task.FromResult(JsonSerializer.SerializeToElement(new
{
state="InGame",nextState="InGame",tradePauseIdentity=new{verified=true,entityId="42",hullFraction=1d,observedAtUnixMilliseconds=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}
}));
public Task RenewAsync(string owner,string run,ulong entity,CancellationToken token){Renewals++;return Task.CompletedTask;}
public Task ResumeAsync(string owner,string run,string request,ulong sequence,CancellationToken token)=>throw new InvalidOperationException("An InGame observation never needs a menu action.");
public Task EndAsync(string owner,string run){Ends++;return Task.CompletedTask;}
}
private sealed class Lease(Func<bool> allowed,TradePauseRecoverySession session):IDisposable
{
public bool Disposed;public bool Allowed=>allowed();
public Task StepAsync()=>session.StepAsync(default);
public void Dispose(){Check.That(!Allowed);Disposed=true;session.EndAsync().GetAwaiter().GetResult();}
}
private static PlayerState Player=>new(){Ship="Type9",InGame=true,CommanderId="engine-menu-fixture",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),
CargoReliable=true,CreditsReliable=true,Credits=10000,CargoCapacity=16,Rebuy=100};
private static AutomaticTradeConfiguration Configuration(string directory)=>new(){WorkingDirectory=directory,
Source=new(){MarketId=1,Name="Source",SystemName="System",MaxLandingPad=LandingPad.Large},Destination=new(){MarketId=2,Name="Destination",SystemName="System",MaxLandingPad=LandingPad.Large}};
[Test]public async Task ActualEngineRevokesBeforeRelogAndCreatesANewSessionScopeAfterFastReentry()
{
using var temporary=new TemporaryDirectory();using var cancellation=new CancellationTokenSource(TimeSpan.FromSeconds(5));
var current=Player;var channel=new Commerce();var menus=new Menus();var leases=new List<Lease>();var visits=0;var recoveries=0;var released=0;var preflightObserved=false;
var protectionChecks=0;var recoveryProtectionObserved=false;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),async(_,_,token)=>
{
if(++visits==1)
{
Check.Equal(1,leases.Count);await leases[0].StepAsync();Check.Equal(1,menus.Renewals);
throw new DockingHandoffException("fixture same-mode recovery",2);
}
Check.Equal(2,leases.Count);Check.That(leases[0].Disposed&&!leases[0].Allowed&&leases[1].Allowed);
await leases[1].StepAsync();Check.That(menus.Renewals==2,"The replacement session must bind the new LoadGameAt rather than retaining the old baseline.");
cancellation.Cancel();token.ThrowIfCancellationRequested();
},_=>Task.CompletedTask,()=>{released++;return Task.CompletedTask;})
{
RunningMenuRecoveryFactory=(allowed,_)=>
{
var lease=new Lease(allowed,new TradePauseRecoverySession(menus,()=>current,allowed));leases.Add(lease);return lease;
}
};
Check.Equal(0,leases.Count);
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>
{
protectionChecks++;
if(visits==1&&leases[0].Disposed&&released==1)recoveryProtectionObserved=true;
return Task.CompletedTask;
},temporary.Path,_=>
{
Check.That(leases.Single().Disposed&&!leases.Single().Allowed);Check.Equal(1,menus.Ends);
Check.That(recoveryProtectionObserved&&protectionChecks>=2,"A failed handoff requires released inputs and a fresh healthy context before recovery menu actions.");
current=current with{LoadGameAt=current.LoadGameAt!.Value.AddSeconds(1)};recoveries++;return Task.CompletedTask;
},validateFlight:_=>{Check.Equal(0,leases.Count);return Task.CompletedTask;});
engine.Progress+=progress=>{if(progress.Phase=="preflight"){preflightObserved=true;Check.Equal(0,leases.Count);}};
await engine.RunAsync(cancellation.Token).WaitAsync(TimeSpan.FromSeconds(3));
Check.That(preflightObserved);Check.Equal(1,recoveries);Check.Equal(2,visits);Check.Equal(2,released);
Check.That(leases.All(lease=>lease.Disposed&&!lease.Allowed));Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
[Test]public async Task AnActualProtectiveRestartBreaksSafetyWaitingWithoutAnyFurtherInput()
{
using var temporary=new TemporaryDirectory();var current=Player;var channel=new Commerce();var pending=false;var visits=0;var released=0;var safetyPause=false;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),(_,_,_)=>
{visits++;pending=true;return Task.FromException(new FlightThreatPauseException());},_=>Task.CompletedTask,()=>{released++;return Task.CompletedTask;});
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,sessionRestartPending:()=>pending);
engine.Progress+=progress=>safetyPause|=progress.Phase=="safety-pause";
var restartObserved=false;try{await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(2));}catch(FlightSessionRestartRequiredException){restartObserved=true;}
Check.That(restartObserved&&safetyPause);Check.Equal(1,visits);Check.That(released>=2);
Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
[Test]public async Task ActualSessionRecoveriesRemainBoundedAfterManyTransientReads()
{
using var temporary=new TemporaryDirectory();var current=Player;var channel=new Commerce();var visits=0;var recoveries=0;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),(_,_,_)=>
{
if(++visits%5!=0)throw new FlightTelemetryUnavailableException("fixture fresh-frame retry");
throw new DockingHandoffException("fixture handoff still unavailable",2);
},_=>Task.CompletedTask);
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,_=>
{recoveries++;current=current with{LoadGameAt=current.LoadGameAt!.Value.AddSeconds(1)};return Task.CompletedTask;});
var refused=false;try{await engine.RunAsync(default).WaitAsync(TimeSpan.FromSeconds(5));}catch(DockingHandoffException){refused=true;}
Check.That(refused);Check.Equal(3,recoveries);Check.Equal(20,visits);
Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
[Test]public async Task EngineInterfaceAndFlightPreparationFailuresNeverCreateARunningScope()
{
foreach(var rejectDescribe in new[]{true,false})
{
using var temporary=new TemporaryDirectory();var current=Player;var channel=new Commerce{RejectDescribe=rejectDescribe};var scopes=0;var released=0;
var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),(_,_,_)=>throw new InvalidOperationException("Flight must not start"),_=>Task.CompletedTask,
()=>{released++;return Task.CompletedTask;}){RunningMenuRecoveryFactory=(_,_)=>{scopes++;throw new InvalidOperationException("Scope must not start");}};
var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,
validateFlight:_=>throw new NotSupportedException("fixture flight interface unavailable"));
var refused=false;try{await engine.RunAsync(default);}catch(NotSupportedException){refused=true;}
Check.That(refused);Check.Equal(0,scopes);Check.Equal(1,released);Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
}
}
}