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 Calls=[]; internal Action? OnDepart; internal Action? OnReady; internal Func? Departure; internal Func? DepartureContinuation; internal readonly List 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 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 PlanInterstellarAsync(string destination,CancellationToken token){Calls.Add("plan");return Task.FromResult(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 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 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 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 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? FuelPreparation; internal Action? OnFuelReturn; internal int Shortages,FuelReturns; internal bool FuelMassIncreased; public async Task 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 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 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.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.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.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.Player);var phases=new List();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)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.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.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(); 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.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 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 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); } }