using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class FlightAutomationCoordinatorTests
{
[Test]public void TypedDepartureRecoveryReachesTheTradeEntryWithoutBeingWrapped()
{
var failure=new StationDepartureRecoveryException(4210924803,TimeSpan.FromMinutes(3));
try{new FlightAutomationResult(NavigationRunState.Failed,failure).RequireCompleted(default);}
catch(StationDepartureRecoveryException observed){Check.That(ReferenceEquals(failure,observed));return;}
throw new InvalidOperationException("Departure recovery signal was lost.");
}
private const string Build="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static readonly DateTimeOffset Session=DateTimeOffset.UtcNow.AddMinutes(-5);
private static NavWaypoint Point(long id)=>new(id,"System "+id,new(id,0,0),"K");
private static PlayerState Initial=>new(){InGame=true,Docked=true,MarketId=10,StationName="Station 10",StationServicesKnown=true,StationServices=["refuel"],CargoReliable=true,
SystemName="System 1",CommanderId="F1",CurrentShipId=7,LoadGameAt=Session,
Ships=[new(){ShipId=7,HullHealth=1,Modules=[new(){Item="int_dockingcomputer_advanced",Enabled=true,Health=1}]}],
Navigation=new(){CurrentSystemAddress=1,TargetSystemAddress=2,Route=[Point(1),Point(2)]}};
private static readonly Station Destination=new(){MarketId=20,Name="Station 20",SystemName="System 2"};
private class Journey:IFlightJourneyDriver,ISystemFlightControlDriver,IFlightControlDriver,IFlightJourneyFailureClassifier
{
internal PlayerState Player=Initial;
internal readonly List<string> Calls=[];
internal Action? OnDepart;
internal Action? OnReady;
internal Func<CancellationToken,Task>? Departure;
internal Func<long?,CancellationToken,Task>? DepartureContinuation;
internal readonly List<long?> ClearanceMarkets=[];
internal Action? OnInspect;
internal bool Arrive=true,AutoLand=true;
internal bool ManualOverride;
internal Exception? LocalInspectionFailure,ClassifiedFailure;
internal Exception? GalaxyReleaseFailure;
internal int Classifications;
public Task<Exception> ClassifyFailureAsync(Exception failure,CancellationToken token)
{token.ThrowIfCancellationRequested();Calls.Add("classify");Classifications++;return Task.FromResult(ClassifiedFailure??failure);}
public Task EnsureReadyAsync(CancellationToken token){Calls.Add("ready");OnReady?.Invoke();return Task.CompletedTask;}
public async Task DepartAsync(CancellationToken token)
{
Calls.Add("depart");if(Departure is not null)await Departure(token);
Player=Player with{Docked=false,Navigation=Player.Navigation with{UndockedAt=DateTimeOffset.UtcNow}};OnDepart?.Invoke();
}
public async Task EnsureDepartureClearanceAsync(long? localDestinationMarket,CancellationToken token)
{
token.ThrowIfCancellationRequested();ClearanceMarkets.Add(localDestinationMarket);
// This fixture normally represents an inactive original launch;
// continuation scenarios explicitly supply its bounded observation.
if(DepartureContinuation is {} continuation){Calls.Add("continue-departure");await continuation(localDestinationMarket,token);}
}
public Task<IFlightControlDriver> PlanInterstellarAsync(string destination,CancellationToken token){Calls.Add("plan");return Task.FromResult<IFlightControlDriver>(this);}
public Task<(SystemNavigationTarget,ISystemFlightControlDriver)> ResolveArrivalAsync(Station destination,CancellationToken token)
{Calls.Add("resolve");return Task.FromResult<(SystemNavigationTarget,ISystemFlightControlDriver)>((new(Player.Navigation.CurrentSystemAddress!.Value,100,destination.Name,SystemTargetKind.Station,destination.MarketId),this));}
public Task BeginNavigationSessionAsync(CancellationToken token){Calls.Add("begin");return Task.CompletedTask;}
public virtual Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{
Calls.Add("preflight");Check.That(!Player.Docked,"Jump driver cannot run before confirmed departure.");
if(Arrive)Player=Player with{SystemName="System 2",Navigation=Player.Navigation with{CurrentSystemAddress=2}};
return Task.FromResult(new FlightDriverCapabilities(true,Build,true,true,true,true,true,"isolated journey sequence fixture"));
}
public virtual Task SelectTargetAsync(NavWaypoint next,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
public virtual Task<FlightObservation> ReadAsync(CancellationToken token)=>throw new InvalidOperationException("Unexpected live flight read in fixture");
public Task ApplyAsync(FlightDecision decision,long sequence,NavWaypoint next,DateTimeOffset expires,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
public virtual Task ReleaseAsync(){Calls.Add("release-galaxy");if(GalaxyReleaseFailure is {} failure)throw failure;return Task.CompletedTask;}
public Task<SystemNavigationCapabilities> InspectAsync(CancellationToken token){Calls.Add("inspect");OnInspect?.Invoke();token.ThrowIfCancellationRequested();if(LocalInspectionFailure is {} error)throw error;return Task.FromResult(new SystemNavigationCapabilities(Build,true,true,true,true,true,true,"isolated arrival fixture"));}
private SystemNavigationTarget? target;
public Task SelectAsync(SystemNavigationTarget selected,CancellationToken token){Calls.Add("select");target=selected;return Task.CompletedTask;}
public Task PrepareAsync(CancellationToken token){Calls.Add("prepare");Check.That(!Player.Docked);return Task.CompletedTask;}
public Task<SystemFlightFrame> ReadSystemFlightAsync(CancellationToken token)
{
Calls.Add("read");if(AutoLand)Player=Player with{Docked=true,MarketId=target!.MarketId,StationName=target.Name};
return Task.FromResult(new SystemFlightFrame{Sequence=1,ObservedAt=DateTimeOffset.UtcNow,GameSha256=Build,LoadoutFingerprint="fit",ShipEntity=17,
SystemAddress=Player.Navigation.CurrentSystemAddress!.Value,ReferenceWorld=100,SelectedTarget=target,GeometryVerified=true,InGame=true,PlayerOverride=ManualOverride,
Mode=LocalFlightMode.Docked,DockedMarketId=target!.MarketId,Forward=new(0,0,1),Up=new(0,1,0),MaximumNormalSpeed=100,MaximumCruiseSpeed=1e9,
BrakingAcceleration=10,TranslationAcceleration=10,LegalApproachSpeed=100});
}
public Task ApplySystemFlightAsync(SystemFlightDecision decision,SystemFlightFrame frame,SystemNavigationTarget selected,DateTimeOffset expires,CancellationToken token)
{Calls.Add("apply-local");return Task.CompletedTask;}
public Task ReleaseSystemFlightAsync(){Calls.Add("release-local");return Task.CompletedTask;}
}
private sealed class FuelJourney:Journey,IFlightFuelPreparationDriver
{
internal Func<bool,CancellationToken,Task>? FuelPreparation;
internal Action? OnFuelReturn;
internal int Shortages,FuelReturns;
internal bool FuelMassIncreased;
public async Task<bool> PrepareDepartureFuelAsync(bool interstellar,CancellationToken token,bool refuel=true)
{
Calls.Add(!refuel?"verify-fuel-budget":interstellar?"fuel-interstellar":"fuel-local");token.ThrowIfCancellationRequested();
if(FuelPreparation is not null)await FuelPreparation(interstellar,token);
return refuel&&FuelMassIncreased;
}
public Task<(SystemNavigationTarget,ISystemFlightControlDriver)> ResolveFuelReturnAsync(Station origin,CancellationToken token)
{Calls.Add("fuel-return");FuelReturns++;OnFuelReturn?.Invoke();return ResolveArrivalAsync(origin,token);}
public override Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{Arrive=Shortages==0;return base.PreflightAsync(token);}
public override Task SelectTargetAsync(NavWaypoint next,CancellationToken token){Calls.Add("select-galaxy");return Task.CompletedTask;}
public override Task<FlightObservation> ReadAsync(CancellationToken token)
{
Shortages--;return Task.FromResult(new FlightObservation(new(){Sequence=1,ObservedAt=DateTimeOffset.UtcNow,GameSha256=Build,
LoadoutFingerprint="fit",GeometryVerified=true,SystemAddress=1,TargetSystemAddress=2,PlayerOverride=ManualOverride,
FuelMain=1,FuelRequiredForJump=1,FuelNeededToNextRefuel=127.99,MinimumFuelToNextRefuel=2,ScoopPowered=false},null));
}
}
[Test]public async Task CrossHostDepartureContinuationFinishesBeforeGalaxyTradeOrLocalDriversCanAcquireFlightInputs()
{
foreach(var entry in new[]{"galaxy","trade","local"})
{
var driver=new Journey{Player=Initial with{Docked=false,MarketId=null}};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);var clear=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.DepartureContinuation=async(_,token)=>{entered.TrySetResult();await clear.Task.WaitAsync(token);};
var run=entry=="galaxy"?flights.NavigateGalaxyAsync("System 2",driver,driver)
:entry=="trade"?flights.TravelToStationAsync(Destination,driver)
:flights.NavigateLocalAsync(new(1,100,"Station 20",SystemTargetKind.Station,20),driver);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!driver.Calls.Any(call=>call is "plan" or "begin" or "preflight" or "select" or "prepare" or "apply-local"),string.Join(',',driver.Calls));
clear.SetResult();var result=await run.WaitAsync(TimeSpan.FromSeconds(2));Check.Equal(NavigationRunState.Completed,result.State);
Check.That(driver.ClearanceMarkets.Count>0);Check.That(!driver.Calls.Contains("depart"),"An inherited launch must not send a second launch request.");
}
}
[Test]public async Task CancelledOrUnsafeInheritedDepartureNeverPlansOrAcquiresTheNextFlightPhase()
{
foreach(var cancel in new[]{true,false})
{
var driver=new Journey{Player=Initial with{Docked=false,MarketId=null}};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);using var stop=new CancellationTokenSource();
driver.DepartureContinuation=async(_,token)=>{entered.TrySetResult();if(!cancel)throw new FlightHealthPauseException();await Task.Delay(Timeout.Infinite,token);};
var run=flights.TravelToStationAsync(Destination,driver,stop.Token);await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));if(cancel)stop.Cancel();
var result=await run.WaitAsync(TimeSpan.FromSeconds(2));Check.Equal(cancel?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
if(!cancel)Check.That(result.Failure is FlightHealthPauseException);
Check.That(!driver.Calls.Any(call=>call is "plan" or "begin" or "preflight" or "resolve" or "select" or "apply-local"));
}
}
[Test]public async Task NormalSameOriginReturnKeepsItsLocalDestinationAndDoesNotRequireANewUndockReceipt()
{
var old=DateTimeOffset.UtcNow.AddMinutes(-1);var driver=new Journey{Player=Initial with{Docked=false,MarketId=null,Navigation=Initial.Navigation with{UndockedAt=old}}};
driver.DepartureContinuation=(market,_)=>{Check.Equal(10L,market!.Value);return Task.CompletedTask;};
var flights=new FlightAutomationCoordinator(()=>driver.Player);var result=await flights.NavigateStationAsync(new(){MarketId=10,Name="Station 10",SystemName="System 1"},driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal(old,driver.Player.Navigation.UndockedAt!.Value);Check.That(!driver.Calls.Contains("depart"));
}
[Test]public async Task BothDockedStationEntriesPlanThenConfirmFuelBeforeTheyDepart()
{
foreach(var trade in new[]{false,true})
{
var driver=new FuelJourney();var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=trade?await flights.TravelToStationAsync(Destination,driver):await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);
Check.That(driver.Calls.IndexOf("plan")<driver.Calls.IndexOf("fuel-interstellar")&&driver.Calls.IndexOf("fuel-interstellar")<driver.Calls.IndexOf("depart"));
}
}
[Test]public async Task OnlyAConfirmedFuelMassIncreaseReleasesTheOldDriverAndReplansOnceBeforeDeparture()
{
foreach(var increased in new[]{false,true})
{
var driver=new FuelJourney{FuelMassIncreased=increased};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);
Check.Equal(increased?2:1,driver.Calls.Count(value=>value=="plan"));
Check.Equal(1,driver.Calls.Count(value=>value=="fuel-interstellar"));
Check.Equal(increased?1:0,driver.Calls.Count(value=>value=="verify-fuel-budget"));
if(increased)
{
var secondPlan=driver.Calls.FindLastIndex(value=>value=="plan");
Check.That(driver.Calls.IndexOf("fuel-interstellar")<driver.Calls.IndexOf("release-galaxy")
&&driver.Calls.IndexOf("release-galaxy")<secondPlan&&secondPlan<driver.Calls.IndexOf("verify-fuel-budget")
&&driver.Calls.IndexOf("verify-fuel-budget")<driver.Calls.IndexOf("depart"));
}
}
}
[Test]public async Task FuelRefusalAndUserCancellationCannotLeaveTheDockOrStartJumping()
{
foreach(var cancelled in new[]{false,true})
{
var driver=new FuelJourney();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.FuelPreparation=(_,token)=>{if(cancelled){flights.Stop();token.ThrowIfCancellationRequested();}throw new NavigationFuelResupplyRequiredException(1,1,2);};
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(cancelled?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
Check.That(!driver.Calls.Contains("depart")&&!driver.Calls.Contains("begin")&&driver.Calls.Contains("release-galaxy"));
}
}
[Test]public async Task ARealMidflightShortfallReturnsOnceToItsConfirmedOriginAndResumesTheOriginalDestination()
{
var driver=new FuelJourney{Shortages=1};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal(1,driver.FuelReturns);Check.Equal(2,driver.Calls.Count(value=>value=="plan"));
Check.Equal(2,driver.Calls.Count(value=>value=="fuel-interstellar"));Check.Equal(2,driver.Calls.Count(value=>value=="depart"));
Check.That(driver.Player.Docked&&driver.Player.MarketId==Destination.MarketId);
Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("fuel-return"));
}
[Test]public async Task RepeatedShortfallUnknownOriginAndManualTakeoverNeverLoopOrGuessAnotherStation()
{
foreach(var scenario in new[]{"repeated","no-origin","manual"})
{
var driver=new FuelJourney{Shortages=scenario=="repeated"?2:1,ManualOverride=scenario=="manual"};
if(scenario=="no-origin")driver.Player=driver.Player with{Docked=false,StationName="",MarketId=null};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(Destination,driver);
Check.Equal(scenario=="manual"?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
Check.Equal(scenario=="repeated"?1:0,driver.FuelReturns);
if(scenario!="manual")Check.That(result.Failure is NavigationFuelResupplyRequiredException);
}
}
[Test]public async Task FuelRecoveryCannotContinueAfterIdentityCargoOrUserStopChanges()
{
foreach(var scenario in new[]{"identity","cargo","stop"})
{
var driver=new FuelJourney{Shortages=1};var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.OnFuelReturn=()=>{if(scenario=="stop")flights.Stop();else driver.Player=scenario=="identity"
?driver.Player with{CurrentShipId=8}:driver.Player with{CargoCount=1,Inventory=[new("water","Water",1)]};};
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(scenario=="stop"?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.That(!driver.Calls.Contains("select"));
}
}
[Test]public async Task GalaxyNavigationAlsoConfirmsDockedFuelBeforeDeparture()
{
var driver=new FuelJourney();var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(driver.Calls.IndexOf("fuel-interstellar")<driver.Calls.IndexOf("depart"));
}
[Test]public async Task DockedGalaxyStartConfirmsDepartureBeforeBeginningTheSharedJumpRunner()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var phases=new List<FlightAutomationStage>();flights.Progress+=p=>phases.Add(p.Stage);
var result=await flights.NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal("depart,begin,preflight,release-galaxy",string.Join(',',driver.Calls));
Check.That(phases.IndexOf(FlightAutomationStage.Departing)<phases.IndexOf(FlightAutomationStage.Interstellar));Check.That(!flights.IsActive);
}
[Test]public async Task TradeUsesTheSameDepartureJumpAndArrivalPipelineAndVerifiesTheActualDock()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(driver.Player.Docked&&driver.Player.MarketId==20);
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.Equal(1,driver.Calls.Count(value=>value=="plan"));
Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("select"));Check.Equal("release-local",driver.Calls[^1]);
}
[Test]public async Task DockingAtTheDestinationWhileReadinessRecoversDoesNotLaunchAnotherDeparture()
{
var driver=new Journey();driver.Player=driver.Player with{Docked=false,SystemName="System 2",Navigation=driver.Player.Navigation with{CurrentSystemAddress=2}};
driver.OnReady=()=>driver.Player=driver.Player with{Docked=true,MarketId=20};
var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal("ready",string.Join(',',driver.Calls));
}
[Test]public async Task BothStationEntriesClassifyAFailedLocalLegOnlyAfterItsInputsAreReleased()
{
foreach(var trade in new[]{false,true})
{
var original=new FlightTelemetryUnavailableException("waiting for actual scene");var classified=new SavingSceneRecoveryRequiredException(original);
var driver=new Journey{LocalInspectionFailure=original,ClassifiedFailure=classified};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=trade?await flights.TravelToStationAsync(Destination,driver):await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(classified,result.Failure));Check.Equal(1,driver.Classifications);
Check.That(driver.Calls.IndexOf("release-local")<driver.Calls.IndexOf("classify"));
}
}
[Test]public async Task AManualTakeoverIsNeverPassedToTheSceneFailureClassifier()
{
var driver=new Journey{ManualOverride=true,AutoLand=false};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.Equal(0,driver.Classifications);Check.That(result.Failure is null);
}
[Test]public async Task LocalNavigationAutomaticallyDepartsButStayingAtTheCurrentStationDoesNotLaunch()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
var target=new SystemNavigationTarget(1,100,"Local station",SystemTargetKind.Station,20);
var result=await flights.NavigateLocalAsync(target,driver);Check.Equal(NavigationRunState.Completed,result.State);
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.That(!driver.Calls.Contains("plan"));
driver.Calls.Clear();result=await flights.NavigateLocalAsync(target,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal(0,driver.Calls.Count);
}
[Test]public async Task AStationChosenFromTheGalaxyUsesTheFullJourneyWithoutBecomingATradeRun()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var entries=new List<FlightAutomationEntry>();
flights.Progress+=progress=>entries.Add(progress.Entry);
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(entries.Count>0&&entries.All(entry=>entry==FlightAutomationEntry.Galaxy));
Check.That(driver.Player.Docked&&driver.Player.MarketId==Destination.MarketId);
Check.That(driver.Calls.IndexOf("plan")<driver.Calls.IndexOf("depart"));Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("select"));
}
[Test]public async Task AnUnconfirmedDepartureCannotStartAnotherPhase()
{
var driver=new Journey();driver.OnDepart=()=>driver.Player=driver.Player with{Docked=true};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));
}
[Test]public async Task AnOldUndockReceiptCannotConfirmThisDeparture()
{
var driver=new Journey();driver.OnDepart=()=>driver.Player=driver.Player with{Navigation=driver.Player.Navigation with{UndockedAt=Session}};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));
}
[Test]public async Task DifferentEntriesCannotQueueOrStealAnActiveControlSessionAndStopCancelsDeparture()
{
var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver=new Journey{Departure=async token=>{entered.SetResult();await Task.Delay(Timeout.Infinite,token);}};
var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=flights.NavigateGalaxyAsync("System 2",driver,driver);await entered.Task;
var rejected=false;
try{await flights.TravelToStationAsync(Destination,driver);}catch(InvalidOperationException){rejected=true;}
Check.That(rejected);flights.Stop();var result=await first;
Check.Equal(NavigationRunState.Stopped,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));Check.That(!flights.IsActive);
}
[Test]public async Task HealthProtectionPausePreservesTheExactCauseThroughCancellationAndTradeCompletion()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.Departure=token=>{flights.PauseForSafety(reason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(reason,result.Failure));
try{result.RequireCompleted(default);}catch(FlightHealthPauseException observed){Check.That(ReferenceEquals(reason,observed));return;}
throw new InvalidOperationException("The recoverable health pause was lost.");
}
[Test]public async Task AnInnerRunnerThatConsumesProtectionCancellationStillReturnsItsTypedCauseAfterRelease()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.OnInspect=()=>flights.PauseForSafety(reason);
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(reason,result.Failure));
Check.That(driver.Calls.Contains("release-local"));Check.Equal(0,driver.Classifications);
}
[Test]public async Task ExplicitStopOverridesProtectionPauseInEitherOrder()
{
foreach(var manualFirst in new[]{false,true})
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=token=>
{
if(manualFirst)flights.Stop();
flights.PauseForSafety(new FlightHealthPauseException());
if(!manualFirst)flights.Stop();
token.ThrowIfCancellationRequested();return Task.CompletedTask;
};
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
}
}
[Test]public async Task ExternalCancellationOverridesProtectionAndCannotEnterSafetyRecovery()
{
using var external=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=token=>{flights.PauseForSafety(new FlightHealthPauseException());external.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver,external.Token);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
try{result.RequireCompleted(external.Token);}catch(OperationCanceledException){return;}
throw new InvalidOperationException("A cancelled user token must remain cancelled.");
}
[Test]public async Task ManualOrExternalCancellationAlsoOverridesAConcurrentTypedDriverFailure()
{
foreach(var externalCancel in new[]{false,true})
{
using var external=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=token=>
{
flights.PauseForSafety(new FlightHealthPauseException());
if(externalCancel)external.Cancel();else flights.Stop();
throw new FlightHealthPauseException();
};
var result=await flights.TravelToStationAsync(Destination,driver,external.Token);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
}
}
[Test]public async Task ExplicitStopAlsoOverridesAnInnerRunnerFailedResult()
{
var driver=new Journey();driver.Player=driver.Player with{Docked=false};var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.OnInspect=()=>{flights.PauseForSafety(new FlightHealthPauseException());flights.Stop();throw new FlightHealthPauseException();};
var result=await flights.NavigateLocalAsync(new(1,100,"Local station",SystemTargetKind.Station,20),driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);Check.That(driver.Calls.Contains("release-local"));
}
[Test]public async Task ConcurrentManualAndProtectionStopsAlwaysKeepTheManualStop()
{
for(var attempt=0;attempt<20;attempt++)
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=async token=>
{
await Task.WhenAll(Task.Run(()=>flights.PauseForSafety(new FlightHealthPauseException())),Task.Run(flights.Stop));
token.ThrowIfCancellationRequested();
};
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
}
}
[Test]public async Task ProtectionReasonsBelongOnlyToTheirActiveJourney()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var firstReason=new FlightHealthPauseException();
flights.PauseForSafety(firstReason);
driver.Departure=token=>{flights.PauseForSafety(firstReason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var first=await flights.TravelToStationAsync(Destination,driver);Check.That(ReferenceEquals(firstReason,first.Failure));
flights.PauseForSafety(new FlightThreatPauseException());driver.Departure=token=>{flights.Stop();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var second=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Stopped,second.State);Check.That(second.Failure is null);
driver.Departure=null;var third=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Completed,third.State);Check.That(third.Failure is null);
}
[Test]public async Task OnlyASafetyPauseDuringDepartureCarriesTheActualOriginAndReusingItsCauseClearsThatOrigin()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.Departure=token=>{flights.PauseForSafety(reason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var departure=await flights.TravelToStationAsync(Destination,driver);
Check.That(ReferenceEquals(reason,departure.Failure));Check.Equal(10L,FlightSafetyPauseException.DepartureMarket(reason)!.Value);
Check.Equal(10L,FlightSafetyPauseException.DepartureMarket(new InvalidOperationException("journey wrapper",new AggregateException(reason)))!.Value);
driver.Departure=null;driver.Player=driver.Player with{Docked=false};driver.OnInspect=()=>flights.PauseForSafety(reason);
var local=await flights.TravelToStationAsync(Destination,driver);
Check.That(ReferenceEquals(reason,local.Failure));Check.That(FlightSafetyPauseException.DepartureMarket(reason) is null);
}
[Test]public async Task OnlyAnUndockedDeparturePauseNeedsOneSessionRecoveryAfterTheSafetyWait()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.Departure=token=>{flights.PauseForSafety(reason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);var wrapped=new InvalidOperationException("original journey",result.Failure);
Check.That(FlightSafetyPauseException.RequiresDepartureSessionRecovery(wrapped,docked:false,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(wrapped,docked:true,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(wrapped,docked:false,alreadyRecovered:true));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(new FlightHealthPauseException(),docked:false,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(new FlightThreatPauseException(),docked:false,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(new OperationCanceledException(),docked:false,alreadyRecovered:false));
}
[Test]public async Task CancellationOrIdentityChangeAfterDepartureCannotResumeTheNextLeg()
{
foreach(var cancel in new[]{true,false})
{
using var stop=new CancellationTokenSource();var driver=new Journey();
driver.OnDepart=()=>{if(cancel)stop.Cancel();else driver.Player=driver.Player with{CurrentShipId=8};};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver,stop.Token);
Check.Equal(cancel?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));
}
}
[Test]public async Task VerifiedGameClockLeadDoesNotRejectTheActualDepartureReceipt()
{
var now=DateTimeOffset.UtcNow;var driver=new Journey();
driver.Player=driver.Player with{Status=new(16777232,0,32,1,0,null,null,null,null,[]),StatusAt=now.AddSeconds(6.464),StatusReadAt=now,StatusWrittenAt=now};
driver.OnDepart=()=>driver.Player=driver.Player with{Navigation=driver.Player.Navigation with{UndockedAt=DateTimeOffset.UtcNow.AddSeconds(6.464)}};
var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(driver.Calls.Contains("resolve"));
}
[Test]public async Task MissingAutoLaunchModuleAndMismatchedRouteNeverSendLaunchInput()
{
foreach(var wrongRoute in new[]{false,true})
{
var driver=new Journey();if(!wrongRoute)driver.Player=driver.Player with{Ships=[]};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync(wrongRoute?"System 3":"System 2",driver,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.Equal(0,driver.Calls.Count);
}
}
[Test]public async Task MissingAutoLaunchModuleRejectsBothStationJourneysBeforeAnyPlanningOrFuelPayment()
{
foreach(var local in new[]{false,true})
{
var driver=new FuelJourney{FuelMassIncreased=true};var payments=0;
driver.Player=driver.Player with{Ships=[]};
driver.FuelPreparation=(_,_)=>{payments++;return Task.CompletedTask;};
var destination=local?Destination with{SystemName=driver.Player.SystemName}:Destination;
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(result.Failure is NotSupportedException);
Check.Equal(0,payments);Check.Equal("ready",string.Join(',',driver.Calls));
Check.That(driver.Player.Docked&&driver.Player.MarketId==10);
}
}
[Test]public async Task ReachingTheSystemDoesNotConfirmAnUnwitnessedStationLanding()
{
var driver=new Journey{AutoLand=false};var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(!driver.Player.Docked&&result.Failure!.Message.Contains("实际停靠"));
}
[Test]public async Task MonitoringCancellationPreservesTheOriginalInvalidDataSafetyOrTransportCauseAndRecordsBeforeCancel()
{
foreach(var cause in new Exception[]{new InvalidDataException("identity observation unavailable"),new FlightHealthPauseException(),new IOException("health observation transport")})
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);CancellationToken activeToken=default;var beforeCancel=false;
flights.StopRecorded+=record=>{if(record.Source=="monitoring-pause")beforeCancel=!activeToken.IsCancellationRequested;};
driver.Departure=token=>{activeToken=token;flights.PauseForMonitoring(cause);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(cause,result.Failure)&&beforeCancel);
var diagnostic=flights.LastStop!;Check.Equal("monitoring-pause",diagnostic.Source);Check.That(!diagnostic.ManualVeto&&!diagnostic.ParentTokenCancelled&&ReferenceEquals(cause,diagnostic.Cause));
Check.Equal(FlightAutomationEntry.Trade,diagnostic.Entry!.Value);Check.Equal(FlightAutomationStage.Departing,diagnostic.Stage);Check.Equal(7L,diagnostic.Snapshot!.ShipId!.Value);
Check.That(!flights.IsActive&&flights.LastStop==diagnostic);
}
}
[Test]public async Task ManualVetoKeepsTheFirstMonitoringEvidenceButNeverEntersRecovery()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first monitoring failure");
driver.Departure=token=>{flights.PauseForMonitoring(first);flights.PauseForMonitoring(new IOException("later cleanup"));flights.Stop();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
var record=flights.LastStop!;Check.Equal("monitoring-pause",record.Source);Check.That(record.ManualVeto&&record.ManualVetoAt is not null&&ReferenceEquals(first,record.Cause));
}
[Test]public async Task AMonitoringCauseCannotBeReplacedByTheDriversLaterReleaseFailure()
{
var driver=new Journey{GalaxyReleaseFailure=new IOException("cleanup release failed")};var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first source loss");
driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(first,result.Failure)&&ReferenceEquals(first,flights.LastStop!.Cause));
}
[Test]public async Task SeparateJourneysCannotInheritAnotherRunsStopCauseAndEachCompletedStopRemainsQueryable()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("first run");var second=new InvalidDataException("second run");
driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
Check.That(ReferenceEquals(first,(await flights.TravelToStationAsync(Destination,driver)).Failure));var firstRecord=flights.LastStop!;
driver.Departure=token=>{flights.PauseForMonitoring(second);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
Check.That(ReferenceEquals(second,(await flights.TravelToStationAsync(Destination,driver)).Failure));var secondRecord=flights.LastStop!;
Check.That(firstRecord.RunId!=secondRecord.RunId&&ReferenceEquals(first,firstRecord.Cause)&&ReferenceEquals(second,secondRecord.Cause));
flights.PauseForMonitoring(new IOException("after completion"));Check.That(ReferenceEquals(secondRecord,flights.LastStop));
}
[Test]public async Task AParentCancellationVetoDoesNotEraseTheRecordedMonitoringCause()
{
using var parent=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("monitor before parent");
driver.Departure=token=>{flights.PauseForMonitoring(first);parent.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver,parent.Token);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
Check.That(flights.LastStop!.ParentTokenCancelled&&ReferenceEquals(first,flights.LastStop.Cause));
}
private sealed class LoadingJourney(Func<DateTimeOffset> time,bool failRelease=false):Journey
{
internal readonly TaskCompletionSource ReleaseEntered=new(TaskCreationOptions.RunContinuationsAsynchronously);
internal readonly TaskCompletionSource AllowRelease=new(TaskCreationOptions.RunContinuationsAsynchronously);
private int releaseCount;
public override Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{
Arrive=false;Player=Player with{Navigation=Player.Navigation with{JumpInProgress=true,JumpTargetSystemAddress=2,JumpTargetSystem="System 2",JumpStartedAt=time()}};
return base.PreflightAsync(token);
}
public override async Task ReleaseAsync()
{
Calls.Add("release-galaxy");if(Interlocked.Increment(ref releaseCount)!=1)return;
ReleaseEntered.TrySetResult();await AllowRelease.Task;if(failRelease)throw new IOException("original release was not acknowledged");
}
}
[Test]public async Task InputFreeLoadingOnlyQualifiesAfterSuccessfulReleaseAndForTheExactJumpWithinItsOriginalDeadline()
{
var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
var waiting=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
flights.Interstellar.Progress+=p=>{if(p.Message.Contains("跃迁已开始"))waiting.TrySetResult();};
var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();await waiting.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now));Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75)));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75).AddMilliseconds(1)));
foreach(var changed in new[]{driver.Player with{CommanderId="other"},driver.Player with{CurrentShipId=8},driver.Player with{LoadGameAt=now},
driver.Player with{Navigation=driver.Player.Navigation with{JumpInProgress=false}},driver.Player with{Navigation=driver.Player.Navigation with{JumpTargetSystemAddress=3}},
driver.Player with{Navigation=driver.Player.Navigation with{JumpStartedAt=now.AddSeconds(1)}}})
Check.That(!flights.IsAwaitingInputFreeJump(changed,now));
flights.Stop();Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));Check.Equal(NavigationRunState.Stopped,(await run.WaitAsync(TimeSpan.FromSeconds(2))).State);
}
[Test]public async Task AFailedOriginalReleaseNeverAuthorizesTheLoadingException()
{
var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now,failRelease:true);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();var result=await run.WaitAsync(TimeSpan.FromSeconds(2));
Check.Equal(NavigationRunState.Failed,result.State);Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now)&&flights.Interstellar.AwaitingInputFreeJump is null);
}
}
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class FlightAutomationCoordinatorTests
{
[Test]public void TypedDepartureRecoveryReachesTheTradeEntryWithoutBeingWrapped()
{
var failure=new StationDepartureRecoveryException(4210924803,TimeSpan.FromMinutes(3));
try{new FlightAutomationResult(NavigationRunState.Failed,failure).RequireCompleted(default);}
catch(StationDepartureRecoveryException observed){Check.That(ReferenceEquals(failure,observed));return;}
throw new InvalidOperationException("Departure recovery signal was lost.");
}
private const string Build="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static readonly DateTimeOffset Session=DateTimeOffset.UtcNow.AddMinutes(-5);
private static NavWaypoint Point(long id)=>new(id,"System "+id,new(id,0,0),"K");
private static PlayerState Initial=>new(){InGame=true,Docked=true,MarketId=10,StationName="Station 10",StationServicesKnown=true,StationServices=["refuel"],CargoReliable=true,
SystemName="System 1",CommanderId="F1",CurrentShipId=7,LoadGameAt=Session,
Ships=[new(){ShipId=7,HullHealth=1,Modules=[new(){Item="int_dockingcomputer_advanced",Enabled=true,Health=1}]}],
Navigation=new(){CurrentSystemAddress=1,TargetSystemAddress=2,Route=[Point(1),Point(2)]}};
private static readonly Station Destination=new(){MarketId=20,Name="Station 20",SystemName="System 2"};
private class Journey:IFlightJourneyDriver,ISystemFlightControlDriver,IFlightControlDriver,IFlightJourneyFailureClassifier
{
internal PlayerState Player=Initial;
internal readonly List<string> Calls=[];
internal Action? OnDepart;
internal Action? OnReady;
internal Func<CancellationToken,Task>? Departure;
internal Func<long?,CancellationToken,Task>? DepartureContinuation;
internal readonly List<long?> ClearanceMarkets=[];
internal Action? OnInspect;
internal bool Arrive=true,AutoLand=true;
internal bool ManualOverride;
internal Exception? LocalInspectionFailure,ClassifiedFailure;
internal Exception? GalaxyReleaseFailure;
internal int Classifications;
public Task<Exception> ClassifyFailureAsync(Exception failure,CancellationToken token)
{token.ThrowIfCancellationRequested();Calls.Add("classify");Classifications++;return Task.FromResult(ClassifiedFailure??failure);}
public Task EnsureReadyAsync(CancellationToken token){Calls.Add("ready");OnReady?.Invoke();return Task.CompletedTask;}
public async Task DepartAsync(CancellationToken token)
{
Calls.Add("depart");if(Departure is not null)await Departure(token);
Player=Player with{Docked=false,Navigation=Player.Navigation with{UndockedAt=DateTimeOffset.UtcNow}};OnDepart?.Invoke();
}
public async Task EnsureDepartureClearanceAsync(long? localDestinationMarket,CancellationToken token)
{
token.ThrowIfCancellationRequested();ClearanceMarkets.Add(localDestinationMarket);
// This fixture normally represents an inactive original launch;
// continuation scenarios explicitly supply its bounded observation.
if(DepartureContinuation is {} continuation){Calls.Add("continue-departure");await continuation(localDestinationMarket,token);}
}
public Task<IFlightControlDriver> PlanInterstellarAsync(string destination,CancellationToken token){Calls.Add("plan");return Task.FromResult<IFlightControlDriver>(this);}
public Task<(SystemNavigationTarget,ISystemFlightControlDriver)> ResolveArrivalAsync(Station destination,CancellationToken token)
{Calls.Add("resolve");return Task.FromResult<(SystemNavigationTarget,ISystemFlightControlDriver)>((new(Player.Navigation.CurrentSystemAddress!.Value,100,destination.Name,SystemTargetKind.Station,destination.MarketId),this));}
public Task BeginNavigationSessionAsync(CancellationToken token){Calls.Add("begin");return Task.CompletedTask;}
public virtual Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{
Calls.Add("preflight");Check.That(!Player.Docked,"Jump driver cannot run before confirmed departure.");
if(Arrive)Player=Player with{SystemName="System 2",Navigation=Player.Navigation with{CurrentSystemAddress=2}};
return Task.FromResult(new FlightDriverCapabilities(true,Build,true,true,true,true,true,"isolated journey sequence fixture"));
}
public virtual Task SelectTargetAsync(NavWaypoint next,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
public virtual Task<FlightObservation> ReadAsync(CancellationToken token)=>throw new InvalidOperationException("Unexpected live flight read in fixture");
public Task ApplyAsync(FlightDecision decision,long sequence,NavWaypoint next,DateTimeOffset expires,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
public virtual Task ReleaseAsync(){Calls.Add("release-galaxy");if(GalaxyReleaseFailure is {} failure)throw failure;return Task.CompletedTask;}
public Task<SystemNavigationCapabilities> InspectAsync(CancellationToken token){Calls.Add("inspect");OnInspect?.Invoke();token.ThrowIfCancellationRequested();if(LocalInspectionFailure is {} error)throw error;return Task.FromResult(new SystemNavigationCapabilities(Build,true,true,true,true,true,true,"isolated arrival fixture"));}
private SystemNavigationTarget? target;
public Task SelectAsync(SystemNavigationTarget selected,CancellationToken token){Calls.Add("select");target=selected;return Task.CompletedTask;}
public Task PrepareAsync(CancellationToken token){Calls.Add("prepare");Check.That(!Player.Docked);return Task.CompletedTask;}
public Task<SystemFlightFrame> ReadSystemFlightAsync(CancellationToken token)
{
Calls.Add("read");if(AutoLand)Player=Player with{Docked=true,MarketId=target!.MarketId,StationName=target.Name};
return Task.FromResult(new SystemFlightFrame{Sequence=1,ObservedAt=DateTimeOffset.UtcNow,GameSha256=Build,LoadoutFingerprint="fit",ShipEntity=17,
SystemAddress=Player.Navigation.CurrentSystemAddress!.Value,ReferenceWorld=100,SelectedTarget=target,GeometryVerified=true,InGame=true,PlayerOverride=ManualOverride,
Mode=LocalFlightMode.Docked,DockedMarketId=target!.MarketId,Forward=new(0,0,1),Up=new(0,1,0),MaximumNormalSpeed=100,MaximumCruiseSpeed=1e9,
BrakingAcceleration=10,TranslationAcceleration=10,LegalApproachSpeed=100});
}
public Task ApplySystemFlightAsync(SystemFlightDecision decision,SystemFlightFrame frame,SystemNavigationTarget selected,DateTimeOffset expires,CancellationToken token)
{Calls.Add("apply-local");return Task.CompletedTask;}
public Task ReleaseSystemFlightAsync(){Calls.Add("release-local");return Task.CompletedTask;}
}
private sealed class FuelJourney:Journey,IFlightFuelPreparationDriver
{
internal Func<bool,CancellationToken,Task>? FuelPreparation;
internal Action? OnFuelReturn;
internal int Shortages,FuelReturns;
internal bool FuelMassIncreased;
public async Task<bool> PrepareDepartureFuelAsync(bool interstellar,CancellationToken token,bool refuel=true)
{
Calls.Add(!refuel?"verify-fuel-budget":interstellar?"fuel-interstellar":"fuel-local");token.ThrowIfCancellationRequested();
if(FuelPreparation is not null)await FuelPreparation(interstellar,token);
return refuel&&FuelMassIncreased;
}
public Task<(SystemNavigationTarget,ISystemFlightControlDriver)> ResolveFuelReturnAsync(Station origin,CancellationToken token)
{Calls.Add("fuel-return");FuelReturns++;OnFuelReturn?.Invoke();return ResolveArrivalAsync(origin,token);}
public override Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{Arrive=Shortages==0;return base.PreflightAsync(token);}
public override Task SelectTargetAsync(NavWaypoint next,CancellationToken token){Calls.Add("select-galaxy");return Task.CompletedTask;}
public override Task<FlightObservation> ReadAsync(CancellationToken token)
{
Shortages--;return Task.FromResult(new FlightObservation(new(){Sequence=1,ObservedAt=DateTimeOffset.UtcNow,GameSha256=Build,
LoadoutFingerprint="fit",GeometryVerified=true,SystemAddress=1,TargetSystemAddress=2,PlayerOverride=ManualOverride,
FuelMain=1,FuelRequiredForJump=1,FuelNeededToNextRefuel=127.99,MinimumFuelToNextRefuel=2,ScoopPowered=false},null));
}
}
[Test]public async Task CrossHostDepartureContinuationFinishesBeforeGalaxyTradeOrLocalDriversCanAcquireFlightInputs()
{
foreach(var entry in new[]{"galaxy","trade","local"})
{
var driver=new Journey{Player=Initial with{Docked=false,MarketId=null}};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);var clear=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.DepartureContinuation=async(_,token)=>{entered.TrySetResult();await clear.Task.WaitAsync(token);};
var run=entry=="galaxy"?flights.NavigateGalaxyAsync("System 2",driver,driver)
:entry=="trade"?flights.TravelToStationAsync(Destination,driver)
:flights.NavigateLocalAsync(new(1,100,"Station 20",SystemTargetKind.Station,20),driver);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!driver.Calls.Any(call=>call is "plan" or "begin" or "preflight" or "select" or "prepare" or "apply-local"),string.Join(',',driver.Calls));
clear.SetResult();var result=await run.WaitAsync(TimeSpan.FromSeconds(2));Check.Equal(NavigationRunState.Completed,result.State);
Check.That(driver.ClearanceMarkets.Count>0);Check.That(!driver.Calls.Contains("depart"),"An inherited launch must not send a second launch request.");
}
}
[Test]public async Task CancelledOrUnsafeInheritedDepartureNeverPlansOrAcquiresTheNextFlightPhase()
{
foreach(var cancel in new[]{true,false})
{
var driver=new Journey{Player=Initial with{Docked=false,MarketId=null}};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);using var stop=new CancellationTokenSource();
driver.DepartureContinuation=async(_,token)=>{entered.TrySetResult();if(!cancel)throw new FlightHealthPauseException();await Task.Delay(Timeout.Infinite,token);};
var run=flights.TravelToStationAsync(Destination,driver,stop.Token);await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));if(cancel)stop.Cancel();
var result=await run.WaitAsync(TimeSpan.FromSeconds(2));Check.Equal(cancel?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
if(!cancel)Check.That(result.Failure is FlightHealthPauseException);
Check.That(!driver.Calls.Any(call=>call is "plan" or "begin" or "preflight" or "resolve" or "select" or "apply-local"));
}
}
[Test]public async Task NormalSameOriginReturnKeepsItsLocalDestinationAndDoesNotRequireANewUndockReceipt()
{
var old=DateTimeOffset.UtcNow.AddMinutes(-1);var driver=new Journey{Player=Initial with{Docked=false,MarketId=null,Navigation=Initial.Navigation with{UndockedAt=old}}};
driver.DepartureContinuation=(market,_)=>{Check.Equal(10L,market!.Value);return Task.CompletedTask;};
var flights=new FlightAutomationCoordinator(()=>driver.Player);var result=await flights.NavigateStationAsync(new(){MarketId=10,Name="Station 10",SystemName="System 1"},driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal(old,driver.Player.Navigation.UndockedAt!.Value);Check.That(!driver.Calls.Contains("depart"));
}
[Test]public async Task BothDockedStationEntriesPlanThenConfirmFuelBeforeTheyDepart()
{
foreach(var trade in new[]{false,true})
{
var driver=new FuelJourney();var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=trade?await flights.TravelToStationAsync(Destination,driver):await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);
Check.That(driver.Calls.IndexOf("plan")<driver.Calls.IndexOf("fuel-interstellar")&&driver.Calls.IndexOf("fuel-interstellar")<driver.Calls.IndexOf("depart"));
}
}
[Test]public async Task OnlyAConfirmedFuelMassIncreaseReleasesTheOldDriverAndReplansOnceBeforeDeparture()
{
foreach(var increased in new[]{false,true})
{
var driver=new FuelJourney{FuelMassIncreased=increased};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);
Check.Equal(increased?2:1,driver.Calls.Count(value=>value=="plan"));
Check.Equal(1,driver.Calls.Count(value=>value=="fuel-interstellar"));
Check.Equal(increased?1:0,driver.Calls.Count(value=>value=="verify-fuel-budget"));
if(increased)
{
var secondPlan=driver.Calls.FindLastIndex(value=>value=="plan");
Check.That(driver.Calls.IndexOf("fuel-interstellar")<driver.Calls.IndexOf("release-galaxy")
&&driver.Calls.IndexOf("release-galaxy")<secondPlan&&secondPlan<driver.Calls.IndexOf("verify-fuel-budget")
&&driver.Calls.IndexOf("verify-fuel-budget")<driver.Calls.IndexOf("depart"));
}
}
}
[Test]public async Task FuelRefusalAndUserCancellationCannotLeaveTheDockOrStartJumping()
{
foreach(var cancelled in new[]{false,true})
{
var driver=new FuelJourney();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.FuelPreparation=(_,token)=>{if(cancelled){flights.Stop();token.ThrowIfCancellationRequested();}throw new NavigationFuelResupplyRequiredException(1,1,2);};
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(cancelled?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
Check.That(!driver.Calls.Contains("depart")&&!driver.Calls.Contains("begin")&&driver.Calls.Contains("release-galaxy"));
}
}
[Test]public async Task ARealMidflightShortfallReturnsOnceToItsConfirmedOriginAndResumesTheOriginalDestination()
{
var driver=new FuelJourney{Shortages=1};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal(1,driver.FuelReturns);Check.Equal(2,driver.Calls.Count(value=>value=="plan"));
Check.Equal(2,driver.Calls.Count(value=>value=="fuel-interstellar"));Check.Equal(2,driver.Calls.Count(value=>value=="depart"));
Check.That(driver.Player.Docked&&driver.Player.MarketId==Destination.MarketId);
Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("fuel-return"));
}
[Test]public async Task RepeatedShortfallUnknownOriginAndManualTakeoverNeverLoopOrGuessAnotherStation()
{
foreach(var scenario in new[]{"repeated","no-origin","manual"})
{
var driver=new FuelJourney{Shortages=scenario=="repeated"?2:1,ManualOverride=scenario=="manual"};
if(scenario=="no-origin")driver.Player=driver.Player with{Docked=false,StationName="",MarketId=null};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(Destination,driver);
Check.Equal(scenario=="manual"?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
Check.Equal(scenario=="repeated"?1:0,driver.FuelReturns);
if(scenario!="manual")Check.That(result.Failure is NavigationFuelResupplyRequiredException);
}
}
[Test]public async Task FuelRecoveryCannotContinueAfterIdentityCargoOrUserStopChanges()
{
foreach(var scenario in new[]{"identity","cargo","stop"})
{
var driver=new FuelJourney{Shortages=1};var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.OnFuelReturn=()=>{if(scenario=="stop")flights.Stop();else driver.Player=scenario=="identity"
?driver.Player with{CurrentShipId=8}:driver.Player with{CargoCount=1,Inventory=[new("water","Water",1)]};};
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(scenario=="stop"?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.That(!driver.Calls.Contains("select"));
}
}
[Test]public async Task GalaxyNavigationAlsoConfirmsDockedFuelBeforeDeparture()
{
var driver=new FuelJourney();var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(driver.Calls.IndexOf("fuel-interstellar")<driver.Calls.IndexOf("depart"));
}
[Test]public async Task DockedGalaxyStartConfirmsDepartureBeforeBeginningTheSharedJumpRunner()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var phases=new List<FlightAutomationStage>();flights.Progress+=p=>phases.Add(p.Stage);
var result=await flights.NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal("depart,begin,preflight,release-galaxy",string.Join(',',driver.Calls));
Check.That(phases.IndexOf(FlightAutomationStage.Departing)<phases.IndexOf(FlightAutomationStage.Interstellar));Check.That(!flights.IsActive);
}
[Test]public async Task TradeUsesTheSameDepartureJumpAndArrivalPipelineAndVerifiesTheActualDock()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(driver.Player.Docked&&driver.Player.MarketId==20);
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.Equal(1,driver.Calls.Count(value=>value=="plan"));
Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("select"));Check.Equal("release-local",driver.Calls[^1]);
}
[Test]public async Task DockingAtTheDestinationWhileReadinessRecoversDoesNotLaunchAnotherDeparture()
{
var driver=new Journey();driver.Player=driver.Player with{Docked=false,SystemName="System 2",Navigation=driver.Player.Navigation with{CurrentSystemAddress=2}};
driver.OnReady=()=>driver.Player=driver.Player with{Docked=true,MarketId=20};
var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal("ready",string.Join(',',driver.Calls));
}
[Test]public async Task BothStationEntriesClassifyAFailedLocalLegOnlyAfterItsInputsAreReleased()
{
foreach(var trade in new[]{false,true})
{
var original=new FlightTelemetryUnavailableException("waiting for actual scene");var classified=new SavingSceneRecoveryRequiredException(original);
var driver=new Journey{LocalInspectionFailure=original,ClassifiedFailure=classified};var flights=new FlightAutomationCoordinator(()=>driver.Player);
var result=trade?await flights.TravelToStationAsync(Destination,driver):await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(classified,result.Failure));Check.Equal(1,driver.Classifications);
Check.That(driver.Calls.IndexOf("release-local")<driver.Calls.IndexOf("classify"));
}
}
[Test]public async Task AManualTakeoverIsNeverPassedToTheSceneFailureClassifier()
{
var driver=new Journey{ManualOverride=true,AutoLand=false};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.Equal(0,driver.Classifications);Check.That(result.Failure is null);
}
[Test]public async Task LocalNavigationAutomaticallyDepartsButStayingAtTheCurrentStationDoesNotLaunch()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
var target=new SystemNavigationTarget(1,100,"Local station",SystemTargetKind.Station,20);
var result=await flights.NavigateLocalAsync(target,driver);Check.Equal(NavigationRunState.Completed,result.State);
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.That(!driver.Calls.Contains("plan"));
driver.Calls.Clear();result=await flights.NavigateLocalAsync(target,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal(0,driver.Calls.Count);
}
[Test]public async Task AStationChosenFromTheGalaxyUsesTheFullJourneyWithoutBecomingATradeRun()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var entries=new List<FlightAutomationEntry>();
flights.Progress+=progress=>entries.Add(progress.Entry);
var result=await flights.NavigateStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(entries.Count>0&&entries.All(entry=>entry==FlightAutomationEntry.Galaxy));
Check.That(driver.Player.Docked&&driver.Player.MarketId==Destination.MarketId);
Check.That(driver.Calls.IndexOf("plan")<driver.Calls.IndexOf("depart"));Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("select"));
}
[Test]public async Task AnUnconfirmedDepartureCannotStartAnotherPhase()
{
var driver=new Journey();driver.OnDepart=()=>driver.Player=driver.Player with{Docked=true};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));
}
[Test]public async Task AnOldUndockReceiptCannotConfirmThisDeparture()
{
var driver=new Journey();driver.OnDepart=()=>driver.Player=driver.Player with{Navigation=driver.Player.Navigation with{UndockedAt=Session}};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));
}
[Test]public async Task DifferentEntriesCannotQueueOrStealAnActiveControlSessionAndStopCancelsDeparture()
{
var entered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver=new Journey{Departure=async token=>{entered.SetResult();await Task.Delay(Timeout.Infinite,token);}};
var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=flights.NavigateGalaxyAsync("System 2",driver,driver);await entered.Task;
var rejected=false;
try{await flights.TravelToStationAsync(Destination,driver);}catch(InvalidOperationException){rejected=true;}
Check.That(rejected);flights.Stop();var result=await first;
Check.Equal(NavigationRunState.Stopped,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));Check.That(!flights.IsActive);
}
[Test]public async Task HealthProtectionPausePreservesTheExactCauseThroughCancellationAndTradeCompletion()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.Departure=token=>{flights.PauseForSafety(reason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(reason,result.Failure));
try{result.RequireCompleted(default);}catch(FlightHealthPauseException observed){Check.That(ReferenceEquals(reason,observed));return;}
throw new InvalidOperationException("The recoverable health pause was lost.");
}
[Test]public async Task AnInnerRunnerThatConsumesProtectionCancellationStillReturnsItsTypedCauseAfterRelease()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.OnInspect=()=>flights.PauseForSafety(reason);
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(reason,result.Failure));
Check.That(driver.Calls.Contains("release-local"));Check.Equal(0,driver.Classifications);
}
[Test]public async Task ExplicitStopOverridesProtectionPauseInEitherOrder()
{
foreach(var manualFirst in new[]{false,true})
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=token=>
{
if(manualFirst)flights.Stop();
flights.PauseForSafety(new FlightHealthPauseException());
if(!manualFirst)flights.Stop();
token.ThrowIfCancellationRequested();return Task.CompletedTask;
};
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
}
}
[Test]public async Task ExternalCancellationOverridesProtectionAndCannotEnterSafetyRecovery()
{
using var external=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=token=>{flights.PauseForSafety(new FlightHealthPauseException());external.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver,external.Token);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
try{result.RequireCompleted(external.Token);}catch(OperationCanceledException){return;}
throw new InvalidOperationException("A cancelled user token must remain cancelled.");
}
[Test]public async Task ManualOrExternalCancellationAlsoOverridesAConcurrentTypedDriverFailure()
{
foreach(var externalCancel in new[]{false,true})
{
using var external=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=token=>
{
flights.PauseForSafety(new FlightHealthPauseException());
if(externalCancel)external.Cancel();else flights.Stop();
throw new FlightHealthPauseException();
};
var result=await flights.TravelToStationAsync(Destination,driver,external.Token);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
}
}
[Test]public async Task ExplicitStopAlsoOverridesAnInnerRunnerFailedResult()
{
var driver=new Journey();driver.Player=driver.Player with{Docked=false};var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.OnInspect=()=>{flights.PauseForSafety(new FlightHealthPauseException());flights.Stop();throw new FlightHealthPauseException();};
var result=await flights.NavigateLocalAsync(new(1,100,"Local station",SystemTargetKind.Station,20),driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);Check.That(driver.Calls.Contains("release-local"));
}
[Test]public async Task ConcurrentManualAndProtectionStopsAlwaysKeepTheManualStop()
{
for(var attempt=0;attempt<20;attempt++)
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);
driver.Departure=async token=>
{
await Task.WhenAll(Task.Run(()=>flights.PauseForSafety(new FlightHealthPauseException())),Task.Run(flights.Stop));
token.ThrowIfCancellationRequested();
};
var result=await flights.TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
}
}
[Test]public async Task ProtectionReasonsBelongOnlyToTheirActiveJourney()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var firstReason=new FlightHealthPauseException();
flights.PauseForSafety(firstReason);
driver.Departure=token=>{flights.PauseForSafety(firstReason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var first=await flights.TravelToStationAsync(Destination,driver);Check.That(ReferenceEquals(firstReason,first.Failure));
flights.PauseForSafety(new FlightThreatPauseException());driver.Departure=token=>{flights.Stop();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var second=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Stopped,second.State);Check.That(second.Failure is null);
driver.Departure=null;var third=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Completed,third.State);Check.That(third.Failure is null);
}
[Test]public async Task OnlyASafetyPauseDuringDepartureCarriesTheActualOriginAndReusingItsCauseClearsThatOrigin()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.Departure=token=>{flights.PauseForSafety(reason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var departure=await flights.TravelToStationAsync(Destination,driver);
Check.That(ReferenceEquals(reason,departure.Failure));Check.Equal(10L,FlightSafetyPauseException.DepartureMarket(reason)!.Value);
Check.Equal(10L,FlightSafetyPauseException.DepartureMarket(new InvalidOperationException("journey wrapper",new AggregateException(reason)))!.Value);
driver.Departure=null;driver.Player=driver.Player with{Docked=false};driver.OnInspect=()=>flights.PauseForSafety(reason);
var local=await flights.TravelToStationAsync(Destination,driver);
Check.That(ReferenceEquals(reason,local.Failure));Check.That(FlightSafetyPauseException.DepartureMarket(reason) is null);
}
[Test]public async Task OnlyAnUndockedDeparturePauseNeedsOneSessionRecoveryAfterTheSafetyWait()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var reason=new FlightHealthPauseException();
driver.Departure=token=>{flights.PauseForSafety(reason);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);var wrapped=new InvalidOperationException("original journey",result.Failure);
Check.That(FlightSafetyPauseException.RequiresDepartureSessionRecovery(wrapped,docked:false,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(wrapped,docked:true,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(wrapped,docked:false,alreadyRecovered:true));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(new FlightHealthPauseException(),docked:false,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(new FlightThreatPauseException(),docked:false,alreadyRecovered:false));
Check.That(!FlightSafetyPauseException.RequiresDepartureSessionRecovery(new OperationCanceledException(),docked:false,alreadyRecovered:false));
}
[Test]public async Task CancellationOrIdentityChangeAfterDepartureCannotResumeTheNextLeg()
{
foreach(var cancel in new[]{true,false})
{
using var stop=new CancellationTokenSource();var driver=new Journey();
driver.OnDepart=()=>{if(cancel)stop.Cancel();else driver.Player=driver.Player with{CurrentShipId=8};};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync("System 2",driver,driver,stop.Token);
Check.Equal(cancel?NavigationRunState.Stopped:NavigationRunState.Failed,result.State);Check.Equal("depart,release-galaxy",string.Join(',',driver.Calls));
}
}
[Test]public async Task VerifiedGameClockLeadDoesNotRejectTheActualDepartureReceipt()
{
var now=DateTimeOffset.UtcNow;var driver=new Journey();
driver.Player=driver.Player with{Status=new(16777232,0,32,1,0,null,null,null,null,[]),StatusAt=now.AddSeconds(6.464),StatusReadAt=now,StatusWrittenAt=now};
driver.OnDepart=()=>driver.Player=driver.Player with{Navigation=driver.Player.Navigation with{UndockedAt=DateTimeOffset.UtcNow.AddSeconds(6.464)}};
var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Completed,result.State);Check.That(driver.Calls.Contains("resolve"));
}
[Test]public async Task MissingAutoLaunchModuleAndMismatchedRouteNeverSendLaunchInput()
{
foreach(var wrongRoute in new[]{false,true})
{
var driver=new Journey();if(!wrongRoute)driver.Player=driver.Player with{Ships=[]};
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateGalaxyAsync(wrongRoute?"System 3":"System 2",driver,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.Equal(0,driver.Calls.Count);
}
}
[Test]public async Task MissingAutoLaunchModuleRejectsBothStationJourneysBeforeAnyPlanningOrFuelPayment()
{
foreach(var local in new[]{false,true})
{
var driver=new FuelJourney{FuelMassIncreased=true};var payments=0;
driver.Player=driver.Player with{Ships=[]};
driver.FuelPreparation=(_,_)=>{payments++;return Task.CompletedTask;};
var destination=local?Destination with{SystemName=driver.Player.SystemName}:Destination;
var result=await new FlightAutomationCoordinator(()=>driver.Player).NavigateStationAsync(destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(result.Failure is NotSupportedException);
Check.Equal(0,payments);Check.Equal("ready",string.Join(',',driver.Calls));
Check.That(driver.Player.Docked&&driver.Player.MarketId==10);
}
}
[Test]public async Task ReachingTheSystemDoesNotConfirmAnUnwitnessedStationLanding()
{
var driver=new Journey{AutoLand=false};var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
Check.Equal(NavigationRunState.Failed,result.State);Check.That(!driver.Player.Docked&&result.Failure!.Message.Contains("实际停靠"));
}
[Test]public async Task MonitoringCancellationPreservesTheOriginalInvalidDataSafetyOrTransportCauseAndRecordsBeforeCancel()
{
foreach(var cause in new Exception[]{new InvalidDataException("identity observation unavailable"),new FlightHealthPauseException(),new IOException("health observation transport")})
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);CancellationToken activeToken=default;var beforeCancel=false;
flights.StopRecorded+=record=>{if(record.Source=="monitoring-pause")beforeCancel=!activeToken.IsCancellationRequested;};
driver.Departure=token=>{activeToken=token;flights.PauseForMonitoring(cause);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(cause,result.Failure)&&beforeCancel);
var diagnostic=flights.LastStop!;Check.Equal("monitoring-pause",diagnostic.Source);Check.That(!diagnostic.ManualVeto&&!diagnostic.ParentTokenCancelled&&ReferenceEquals(cause,diagnostic.Cause));
Check.Equal(FlightAutomationEntry.Trade,diagnostic.Entry!.Value);Check.Equal(FlightAutomationStage.Departing,diagnostic.Stage);Check.Equal(7L,diagnostic.Snapshot!.ShipId!.Value);
Check.That(!flights.IsActive&&flights.LastStop==diagnostic);
}
}
[Test]public async Task ManualVetoKeepsTheFirstMonitoringEvidenceButNeverEntersRecovery()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first monitoring failure");
driver.Departure=token=>{flights.PauseForMonitoring(first);flights.PauseForMonitoring(new IOException("later cleanup"));flights.Stop();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
var record=flights.LastStop!;Check.Equal("monitoring-pause",record.Source);Check.That(record.ManualVeto&&record.ManualVetoAt is not null&&ReferenceEquals(first,record.Cause));
}
[Test]public async Task AMonitoringCauseCannotBeReplacedByTheDriversLaterReleaseFailure()
{
var driver=new Journey{GalaxyReleaseFailure=new IOException("cleanup release failed")};var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first source loss");
driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(first,result.Failure)&&ReferenceEquals(first,flights.LastStop!.Cause));
}
[Test]public async Task SeparateJourneysCannotInheritAnotherRunsStopCauseAndEachCompletedStopRemainsQueryable()
{
var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("first run");var second=new InvalidDataException("second run");
driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
Check.That(ReferenceEquals(first,(await flights.TravelToStationAsync(Destination,driver)).Failure));var firstRecord=flights.LastStop!;
driver.Departure=token=>{flights.PauseForMonitoring(second);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
Check.That(ReferenceEquals(second,(await flights.TravelToStationAsync(Destination,driver)).Failure));var secondRecord=flights.LastStop!;
Check.That(firstRecord.RunId!=secondRecord.RunId&&ReferenceEquals(first,firstRecord.Cause)&&ReferenceEquals(second,secondRecord.Cause));
flights.PauseForMonitoring(new IOException("after completion"));Check.That(ReferenceEquals(secondRecord,flights.LastStop));
}
[Test]public async Task AParentCancellationVetoDoesNotEraseTheRecordedMonitoringCause()
{
using var parent=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("monitor before parent");
driver.Departure=token=>{flights.PauseForMonitoring(first);parent.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
var result=await flights.TravelToStationAsync(Destination,driver,parent.Token);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
Check.That(flights.LastStop!.ParentTokenCancelled&&ReferenceEquals(first,flights.LastStop.Cause));
}
private sealed class LoadingJourney(Func<DateTimeOffset> time,bool failRelease=false):Journey
{
internal readonly TaskCompletionSource ReleaseEntered=new(TaskCreationOptions.RunContinuationsAsynchronously);
internal readonly TaskCompletionSource AllowRelease=new(TaskCreationOptions.RunContinuationsAsynchronously);
private int releaseCount;
public override Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
{
Arrive=false;Player=Player with{Navigation=Player.Navigation with{JumpInProgress=true,JumpTargetSystemAddress=2,JumpTargetSystem="System 2",JumpStartedAt=time()}};
return base.PreflightAsync(token);
}
public override async Task ReleaseAsync()
{
Calls.Add("release-galaxy");if(Interlocked.Increment(ref releaseCount)!=1)return;
ReleaseEntered.TrySetResult();await AllowRelease.Task;if(failRelease)throw new IOException("original release was not acknowledged");
}
}
[Test]public async Task InputFreeLoadingOnlyQualifiesAfterSuccessfulReleaseAndForTheExactJumpWithinItsOriginalDeadline()
{
var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
var waiting=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
flights.Interstellar.Progress+=p=>{if(p.Message.Contains("跃迁已开始"))waiting.TrySetResult();};
var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();await waiting.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now));Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75)));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75).AddMilliseconds(1)));
foreach(var changed in new[]{driver.Player with{CommanderId="other"},driver.Player with{CurrentShipId=8},driver.Player with{LoadGameAt=now},
driver.Player with{Navigation=driver.Player.Navigation with{JumpInProgress=false}},driver.Player with{Navigation=driver.Player.Navigation with{JumpTargetSystemAddress=3}},
driver.Player with{Navigation=driver.Player.Navigation with{JumpStartedAt=now.AddSeconds(1)}}})
Check.That(!flights.IsAwaitingInputFreeJump(changed,now));
flights.Stop();Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));Check.Equal(NavigationRunState.Stopped,(await run.WaitAsync(TimeSpan.FromSeconds(2))).State);
}
[Test]public async Task AFailedOriginalReleaseNeverAuthorizesTheLoadingException()
{
var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now,failRelease:true);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();var result=await run.WaitAsync(TimeSpan.FromSeconds(2));
Check.Equal(NavigationRunState.Failed,result.State);Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now)&&flights.Interstellar.AwaitingInputFreeJump is null);
}
}