using XFEExtension.NetCore.XUnit.Attributes; using XFEEDToolBox.Core.Data; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; namespace XFEEDToolBox.Test; [TestFixture] public class NavigationTests { private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-21T00:00:00Z"); private static readonly NavWaypoint Next=new(2,"Next",new(10,0,0),"K"); private static readonly StarEnvelope Star=new(1,"G",Vector3d.Zero,100,115,200); private static ShipThermalCalibration Thermal=>new(){GameSha256="build",LoadoutFingerprint="fit",StarBodyId=1,Verified=true,ExpiresAt=Now.AddHours(1),MinimumHeat=0,MaximumHeat=150,ChargingGainUpperPerSecond=1,ChargedHoldGainUpperPerSecond=1.5,UncertaintyPercent=2,ChargeDurationUpperSeconds=10,DistanceBands=[new(105,1000,1,1)],Evidence="Synthetic calibration for isolated controller tests"}; private static FlightFrame Frame=>new(){Sequence=1,ObservedAt=Now,GameSha256="build",LoadoutFingerprint="fit",GeometryVerified=true,InSupercruise=true,Position=new(150,0,0),Forward=new(0,1,0),Up=new(0,0,1),TargetPosition=new(10000,0,0),SystemAddress=1,TargetSystemAddress=2,HeatPercent=30,FuelMain=16,FuelRequiredForJump=2,FuelNeededToNextRefuel=6,CurrentStar=Star,ScoopPowered=true,Fsd=FsdPhase.Idle,CanBeginCharge=true,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=10,ManoeuvreSecondsUpper=0,RefuelSecondsUpper=8,PlannedMinimumStarDistance=120,PlannedMaximumStarDistance=500}; [Test] public void OnlySupportedMainSequenceClassesAreScoopable() { foreach(var type in new[]{"O","B","A","F","G","K","M"," k "})Check.That(ScoopRules.CanRefuel(type)); foreach(var type in new[]{"L","T","Y","D","DA","W","MS","N","H",""})Check.That(!ScoopRules.CanRefuel(type)); } [Test] public void PublicCatalogueDescriptionsKeepRefuellingRulesConservative() { foreach(var type in new[]{"K (Yellow-Orange) Star","F (White) Star","M (Red dwarf) Star"," b (Blue-White) Star "})Check.That(ScoopRules.CanRefuel(type),type); foreach(var type in new[]{"AEBE (Herbig Ae/Be) Star","MS-type Star","Bizarre","K_UNKNOWN","DA (White dwarf) Star","Neutron Star","Black Hole"})Check.That(!ScoopRules.CanRefuel(type),type); } [Test] public void RoutePreservesLargeIntegerIdsAndRejectsDifferentDestination() { var nav=new NavigationProjector(); nav.Apply(Check.Json("""{"event":"NavRoute","timestamp":"2026-10-21T00:00:00Z","Route":[{"StarSystem":"Here","SystemAddress":9007199254740993,"StarPos":[0,0,0],"StarClass":"G"},{"StarSystem":"Next","SystemAddress":9007199254740995,"StarPos":[10,0,0],"StarClass":"K"}]}""")); nav.Apply(Check.Json("""{"event":"Location","SystemAddress":9007199254740993}""")); var route=NavigationProjector.RemainingRoute(nav.State,"next"); Check.Equal(9007199254740995L,route[0].SystemAddress); Check.Throws(()=>NavigationProjector.RemainingRoute(nav.State,"Elsewhere")); nav.Apply(Check.Json("""{"event":"FSDTarget","SystemAddress":42,"Name":"Elsewhere"}""")); Check.Throws(()=>NavigationProjector.RemainingRoute(nav.State,"Next")); } [Test] public void ClearedRouteCannotBeRestoredByStaleSnapshot() { var nav=new NavigationProjector();var snapshot=Check.Json("""{"event":"NavRoute","timestamp":"2026-10-21T00:00:00Z","Route":[{"StarSystem":"Next","SystemAddress":2,"StarPos":[10,0,0],"StarClass":"K"}]}"""); nav.Apply(snapshot);nav.Apply(Check.Json("""{"event":"NavRouteClear","timestamp":"2026-10-21T00:00:00Z"}"""));nav.Apply(snapshot); Check.Equal(0,nav.State.Route.Count); } [Test] public void InvalidRouteDoesNotReplaceLastValidRoute() { var nav=new NavigationProjector();nav.Apply(Check.Event(new{@event="NavRoute",timestamp=Now,Route=new[]{new{StarSystem="Next",SystemAddress=2,StarPos=new[]{10,0,0},StarClass="K"}}})); Check.Throws(()=>nav.Apply(Check.Json("""{"event":"NavRoute","Route":[{"StarSystem":"Bad","SystemAddress":0,"StarPos":[0,0,0]}],"timestamp":"2026-10-21T00:01:00Z"}"""))); Check.Equal(2L,nav.State.Route[0].SystemAddress); } [Test] public void RouteClearKeepsJumpDestinationAndStaleStartCannotResurrectArrivedJump() { var nav=new NavigationProjector(); nav.Apply(Check.Event(new{@event="StartJump",timestamp=Now,JumpType="Hyperspace",StarSystem="Next",SystemAddress=2})); nav.Apply(Check.Event(new{@event="NavRouteClear",timestamp=Now.AddSeconds(8)})); Check.That(nav.State.JumpInProgress);Check.Equal(2L,nav.State.JumpTargetSystemAddress);Check.Equal("Next",nav.State.JumpTargetSystem); nav.Apply(Check.Event(new{@event="FSDJump",timestamp=Now.AddSeconds(18),StarSystem="Next",SystemAddress=2,FuelUsed=.1,FuelLevel=16})); nav.Apply(Check.Event(new{@event="StartJump",timestamp=Now,JumpType="Hyperspace",StarSystem="Wrong",SystemAddress=3})); Check.That(!nav.State.JumpInProgress);Check.Equal(2L,nav.State.CurrentSystemAddress);Check.Equal(Now.AddSeconds(18),nav.State.LastJumpAt); } [Test] public void HeatWarningsNeverBecomeAnInventedTemperature() { var projector=new PlayerProjector();projector.Apply(Check.Event(new{@event="HeatWarning",timestamp=Now})); Check.That(projector.State.Navigation.HeatAlert); var result=new FlightNavigator().Decide(Frame with{HeatPercent=null},Next,Thermal,Now); Check.That(!result.BeginCharging&&!result.PermitJump&&result.Direction is null); } [Test] public async Task NavRouteFileIsObservedAndOnlyChangesTriggerUpdates() { using var folder=new TemporaryDirectory(); await File.WriteAllTextAsync(folder.File("Journal.0001.log"),"{\"event\":\"Location\",\"SystemAddress\":1}\n"); await File.WriteAllTextAsync(folder.File("NavRoute.json"),"""{"event":"NavRoute","Route":[{"StarSystem":"Here","SystemAddress":1,"StarPos":[0,0,0],"StarClass":"G"},{"StarSystem":"Next","SystemAddress":2,"StarPos":[10,0,0],"StarClass":"K"}]}"""); await using var monitor=new JournalMonitor(folder.Path);var changes=0;monitor.PlayerChanged+=_=>changes++; await monitor.StartAsync();Check.Equal(2,monitor.State.Navigation.Route.Count);var initial=changes; await Task.Delay(900);Check.Equal(initial,changes); await File.WriteAllTextAsync(folder.File("NavRoute.json"),"""{"event":"NavRouteClear","timestamp":"2026-10-21T00:00:01Z"}"""); for(var i=0;i<20&&monitor.State.Navigation.Route.Count>0;i++)await Task.Delay(100); Check.Equal(0,monitor.State.Navigation.Route.Count);Check.Equal(initial+1,changes); } [Test] public void ExactBehindStarUsesSidewaysClearanceRatherThanDeadRadialEscape() { var ship=new Vector3d(150,0,0);var destination=new Vector3d(-10000,0,0); Check.That(FlightGeometry.Obscures(Star,ship,destination)); var direction=FlightGeometry.FastClearDirection(Star,ship,destination,new(0,0,1)); Check.That(Math.Abs(Vector3d.Dot(direction,(destination-ship).Unit()))<1e-9); Check.That(!FlightGeometry.Obscures(Star,ship+direction*200,destination)); var orbit=FlightGeometry.OrbitDirection(Star,ship,destination,new(0,0,1)); Check.That(Math.Abs(Vector3d.Dot(orbit,ship.Unit()))<1e-9); } [Test] public void UnalignedShipCanChargeWhileAvoidingScoopableStar() { var result=new FlightNavigator().Decide(Frame with{TargetPosition=new(-10000,0,0),ManoeuvreSecondsUpper=8},Next,Thermal,Now); Check.That(result.BeginCharging&&!result.PermitJump); Check.That(result.State.Contains("绕星"));Check.That(result.Direction is {} d&&Math.Abs(d.X)<.01); } [Test] public void ClearDepartureCanRequestFullThrottleAndChargeTogether() { var result=new FlightNavigator().Decide(Frame,Next,Thermal,Now); Check.That(result.BeginCharging&&!result.PermitJump); Check.Equal(1d,result.Throttle); } [Test] public void NoScoopUsesShortestNormalClearance() { var result=new FlightNavigator().Decide(Frame with{ScoopPowered=false,TargetPosition=new(-10000,0,0)},Next,Thermal,Now); Check.That(result.BeginCharging&&result.State.Contains("最短侧向")); } [Test] public void NearlyRadialRefuellingKeepsAnOrbitPlaneDespiteFloatRayNoiseAndShipRoll() { var navigator=new FlightNavigator();var first=Frame with{OutwardDepartureRequired=true,PreferredOrbitRadius=125,FuelMain=3, TargetPosition=new(10000,.00001,0),Forward=new(-1,0,0)}; var initial=navigator.Decide(first,Next,Thermal,Now).Direction!.Value; for(var i=1;i<10;i++) { var at=Now.AddMilliseconds(i*40);var row=first with{Sequence=i+1,ObservedAt=at,TargetPosition=new(10000,Math.Sin(i)*.00001,Math.Cos(i)*.00001),Up=new(0,Math.Cos(i),Math.Sin(i))}; var direction=navigator.Decide(row,Next,Thermal,at).Direction!.Value; Check.That(Vector3d.AngleDegrees(initial,direction)<.001,"Orbital guidance must not chase a changing ship-up vector or float ray noise."); } } [Test] public void FullChargeCanWaitForAlignmentAndRequiresStableAlignmentBeforeCommit() { var nav=new FlightNavigator();var frame=Frame with{Fsd=FsdPhase.Charged}; var waiting=nav.Decide(frame,Next,Thermal,Now);Check.That(!waiting.PermitJump&&!waiting.CancelCharging);Check.Equal(0d,waiting.Throttle); frame=frame with{Forward=new(1,0,0),Sequence=2,ObservedAt=Now.AddMilliseconds(100)}; Check.That(!nav.Decide(frame,Next,Thermal,frame.ObservedAt).PermitJump); frame=frame with{Sequence=3,ObservedAt=Now.AddMilliseconds(300)};Check.That(!nav.Decide(frame,Next,Thermal,frame.ObservedAt).PermitJump); frame=frame with{Sequence=4,ObservedAt=Now.AddMilliseconds(450)};var departure=nav.Decide(frame,Next,Thermal,frame.ObservedAt); Check.That(departure.PermitJump);Check.Equal(1d,departure.Throttle); } [Test] public void NormalSpaceAndZeroActualSpeedCanChargeAndCommitAtFullThrottle() { var nav=new FlightNavigator(); var frame=Frame with{InSupercruise=false,SpeedFraction=0,SpeedMetresPerSecond=0,Forward=new(1,0,0)}; var begin=nav.Decide(frame,Next,Thermal,Now); Check.That(begin.BeginCharging&&!begin.EnterSupercruise);Check.Equal(1d,begin.Throttle); frame=frame with{Sequence=2,ObservedAt=Now.AddMilliseconds(200),Fsd=FsdPhase.Charged}; nav.Decide(frame,Next,Thermal,frame.ObservedAt); frame=frame with{Sequence=3,ObservedAt=Now.AddMilliseconds(400)}; var commit=nav.Decide(frame,Next,Thermal,frame.ObservedAt); Check.That(commit.PermitJump);Check.Equal(1d,commit.Throttle); } [Test] public void ChargedHoldingHeatCancelsParallelChargingWhenTotalWouldOverheat() { var model=Thermal with{ChargedHoldGainUpperPerSecond=8}; var result=new FlightNavigator().Decide(Frame with{Fsd=FsdPhase.Charging,ChargeSecondsRemaining=2,ManoeuvreSecondsUpper=8,HeatPercent=70},Next,model,Now); Check.That(result.CancelCharging&&!result.PermitJump&&!result.BeginCharging); Check.That(result.PredictedPeakHeat>100||result.Reason.Contains("超出")); } [Test] public void UnknownOrMismatchedThermalModelAndUnverifiedCommitHoldBlockCharging() { foreach(var model in new ShipThermalCalibration?[]{null,Thermal with{Verified=false},Thermal with{LoadoutFingerprint="different"},Thermal with{ExpiresAt=Now.AddSeconds(-1)},Thermal with{DistanceBands=[new(120,140,1,1),new(150,500,1,1)]}}) Check.That(!new FlightNavigator().Decide(Frame,Next,model,Now).BeginCharging); Check.That(!new FlightNavigator().Decide(Frame with{JumpCommitInterlockVerified=false},Next,Thermal,Now).BeginCharging); } [Test] public void FuelShortageRefuelsOnlyAtConfirmedScoopableStar() { var low=Frame with{FuelMain=3};var scoop=new FlightNavigator().Decide(low,Next,Thermal,Now); Check.That(!scoop.BeginCharging&&scoop.State.Contains("补给")); var neutron=new FlightNavigator().Decide(low with{CurrentStar=Star with{StarClass="N"}},Next,Thermal,Now); Check.That(!neutron.BeginCharging&&neutron.Direction is null); } [Test] public void FuelApproachAndOcclusionMoveFasterThanTheStableScoopHold() { var approaching=Frame with{OutwardDepartureRequired=true,PreferredOrbitRadius=125,FuelMain=3}; var approach=new FlightNavigator().Decide(approaching,Next,Thermal,Now); var scooping=new FlightNavigator().Decide(approaching with{ScoopActive=true},Next,Thermal,Now); var avoiding=new FlightNavigator().Decide(approaching with{FuelMain=16,TargetPosition=new(-10000,0,0)},Next,Thermal,Now); Check.Equal(.55,approach.Throttle);Check.Equal(.2,scooping.Throttle);Check.Equal(.6,avoiding.Throttle); Check.That(!approach.BeginCharging&&!scooping.BeginCharging&&!avoiding.BeginCharging); var hot=new FlightNavigator().Decide(approaching with{HeatPercent=88},Next,Thermal,Now); Check.That(hot.State.Contains("散热")&&!hot.BeginCharging&&hot.Direction!.Value.X>0); } [Test] public void StaleFrameAndPlayerOverrideReleaseControl() { var stale=new FlightNavigator().Decide(Frame with{ObservedAt=Now.AddSeconds(-1),Fsd=FsdPhase.Charging},Next,Thermal,Now); Check.That(stale.CancelCharging&&!stale.PermitJump&&stale.Direction is null); var manual=new FlightNavigator().Decide(Frame with{PlayerOverride=true},Next,Thermal,Now); Check.That(manual.CancelCharging&&manual.Direction is null&&manual.State=="玩家接管"); } [Test] public void CurrentDistanceOutsideForecastEnvelopeCannotCharge() { var result=new FlightNavigator().Decide(Frame with{PlannedMinimumStarDistance=180},Next,Thermal,Now); Check.That(!result.BeginCharging&&result.Reason.Contains("当前恒星距离")); } [Test] public void FuelFormulaUsesCurrentTotalMassAndRejectsOutOfRangeJump() { var fsd=new FsdReference("test",100,10,1,2); Check.Equal(4d,NavigationPreviewBuilder.EstimateFuel(fsd,100,2)); Check.Equal(9d,NavigationPreviewBuilder.EstimateFuel(fsd,150,2)); Check.Throws(()=>NavigationPreviewBuilder.EstimateFuel(fsd,200,2)); } [Test] public void CalibrationMustCoverEntireChargingAndHoldingHorizon() { var model=Thermal with{ExpiresAt=Now.AddSeconds(5)}; var result=new FlightNavigator().Decide(Frame,Next,model,Now); Check.That(!result.BeginCharging&&result.Reason.Contains("有效期")); } [Test] public void RefuelingDurationMustBeBoundedBeforeWaitingWithChargedFsd() { var result=new FlightNavigator().Decide(Frame with{FuelMain=3,Fsd=FsdPhase.Charged,RefuelSecondsUpper=null},Next,Thermal,Now); Check.That(result.CancelCharging&&!result.PermitJump&&result.Reason.Contains("补给耗时")); } [Test] public void PreviewAppliesEngineeringAndBudgetStopsAtNextScoopableStar() { var ship=new ShipRecord{ShipId=1,ConfigurationAt=Now,UnladenMass=400,FuelMain=32,Modules=[new(){Slot="FrameShiftDrive",Item="int_hyperdrive_size6_class5",Enabled=true,Engineering=new(){Modifiers=[new("FSDOptimalMass",2000,1800,false),new("MaxFuelPerJump",10,8,false)]}},new(){Slot="Slot01",Item="int_fuelscoop_size4_class5",Enabled=true}]}; var route=new NavigationJournalState{CurrentSystemAddress=1,Route=[new(1,"Here",new(0,0,0),"G"),new(2,"Fuel",new(10,0,0),"K"),new(3,"End",new(20,0,0),"L")]}; var player=new PlayerState{Navigation=route,CurrentShipId=1,Ships=[ship],CargoReliable=true,CargoCount=10,Status=new(0,0,16,1,10,null,null,null,null,[])}; var preview=NavigationPreviewBuilder.Build(player,"End"); Check.Equal(10.5d,preview.FuelToRefuel!.Value);Check.Equal(2,preview.RemainingJumps);Check.That(preview.NextFuel>0); var noScoop=player with{Ships=[ship with{Modules=[ship.Modules[0]]}]}; Check.Equal(20.5d,NavigationPreviewBuilder.Build(noScoop,"End").FuelToRefuel!.Value); } private static PlayerState Sailing=>new(){InGame=true,Navigation=new(){CurrentSystemAddress=1,TargetSystemAddress=2,Route=[new(1,"Here",new(0,0,0),"G"),Next]}}; private sealed class FlightDriver : IFlightControlDriver { public const string Build="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; public bool Ready=true;public int Reads,Applies,Releases,Selections,Preparations;public bool RejectTarget;public Func? OnRead;public Action? OnApply;public Action? OnPrepare;public Action? OnRelease; public Task PreflightAsync(CancellationToken token)=>Task.FromResult(new FlightDriverCapabilities(Ready,Build,true,true,true,true,true,"Isolated test driver")); public Task SelectTargetAsync(NavWaypoint next,CancellationToken token){Selections++;if(RejectTarget)throw new InvalidOperationException("Target lock was not confirmed");return Task.CompletedTask;} public Task PrepareForNavigationAsync(CancellationToken token){Preparations++;OnPrepare?.Invoke();return Task.CompletedTask;} public Task ReadAsync(CancellationToken token){Check.That(Selections>0,"Target must be locked before reading/applying flight control");return Task.FromResult(OnRead!(++Reads));} public Task ApplyAsync(FlightDecision decision,long expectedSequence,NavWaypoint next,DateTimeOffset expiresAt,CancellationToken token){Applies++;OnApply?.Invoke(decision);return Task.CompletedTask;} public Task ReleaseAsync(){Releases++;OnRelease?.Invoke();return Task.CompletedTask;} } private sealed class SceneClassifier(Func> classify):IFlightJourneyFailureClassifier { public int Reads; public Task ClassifyFailureAsync(Exception failure,CancellationToken token) {Reads++;return classify(failure,token);} } [Test]public async Task AnInterstellarTelemetryGapReturnsAPositiveConnectionFailureOnlyAfterReleasingInput() { var now=Now;var driver=new FlightDriver{OnRead=_=>{now=now.AddSeconds(16);throw new FlightTelemetryUnavailableException("No live cockpit");}}; FlightConnectionRecoveryRequiredException? confirmed=null; var classifier=new SceneClassifier((failure,token)=> { token.ThrowIfCancellationRequested();Check.That(driver.Releases>0);Check.Equal(0,driver.Applies); confirmed=new(new(1,2,FlightDriver.Build,"F1",8,"Solo",Now,0),Check.Json("{}"),Check.Json("{}"),failure); return Task.FromResult(confirmed); }); var runner=new NavigationRunner(()=>Sailing,()=>now); await runner.RunAsync("Next",driver,failureClassifier:classifier); Check.Equal(NavigationRunState.Failed,runner.State);Check.That(ReferenceEquals(confirmed,runner.Failure)); Check.Equal(1,classifier.Reads);Check.Equal(3,driver.Reads);Check.Equal(0,driver.Applies);Check.Equal(1,driver.Selections); } [Test]public async Task UnknownOrLiveScenesKeepWaitingWithoutNewControlAndRemainCancellable() { var now=Now;var driver=new FlightDriver{OnRead=_=>{now=now.AddSeconds(16);throw new FlightTelemetryUnavailableException("Temporary scene transition");}}; var runner=new NavigationRunner(()=>Sailing,()=>now);var classifier=new SceneClassifier((failure,token)=> {Check.That(driver.Releases>0);Check.Equal(0,driver.Applies);return Task.FromResult(failure);}); runner.Progress+=progress=>{if(classifier.Reads>=2)runner.Stop();}; await runner.RunAsync("Next",driver,failureClassifier:classifier); Check.Equal(NavigationRunState.Stopped,runner.State);Check.That(runner.Failure is null); Check.Equal(2,classifier.Reads);Check.Equal(0,driver.Applies);Check.Equal(1,driver.Selections); } [Test]public async Task UnknownNewClassificationIsNotARecoverySignalAndCancellationWinsOverPositiveProof() { foreach(var cancellation in new[]{false,true}) { var now=Now;var driver=new FlightDriver{OnRead=_=>{now=now.AddSeconds(16);throw new FlightTelemetryUnavailableException("missing");}}; using var stop=new CancellationTokenSource();var runner=new NavigationRunner(()=>Sailing,()=>now); var classifier=new SceneClassifier((failure,token)=> { if(cancellation){stop.Cancel();return Task.FromResult(new SavingSceneRecoveryRequiredException(failure));} return Task.FromResult(new InvalidOperationException("An unrecognized different exception is not scene proof")); }); runner.Progress+=progress=>{if(!cancellation&&classifier.Reads>0)runner.Stop();}; await runner.RunAsync("Next",driver,stop.Token,classifier); Check.Equal(NavigationRunState.Stopped,runner.State);Check.That(runner.Failure is null);Check.Equal(0,driver.Applies); } } [Test]public async Task ConfirmedConnectionFailureSurvivesFinalCleanupButASeparateSafetyFailureStillBlocksRecovery() { foreach(var safety in new[]{false,true}) { var now=Now;FlightConnectionRecoveryRequiredException? confirmed=null; var driver=new FlightDriver{OnRead=_=>{now=now.AddSeconds(16);throw new FlightTelemetryUnavailableException("No live cockpit");}}; driver.OnRelease=()=>{if(confirmed is null)return;if(safety)throw new FlightHealthPauseException();throw new IOException("release transport unavailable");}; var classifier=new SceneClassifier((failure,token)=> { confirmed=new(new(1,2,FlightDriver.Build,"F1",8,"Solo",Now,0),Check.Json("{}"),Check.Json("{}"),failure); return Task.FromResult(confirmed); }); var runner=new NavigationRunner(()=>Sailing,()=>now);await runner.RunAsync("Next",driver,failureClassifier:classifier); Check.Equal(NavigationRunState.Failed,runner.State);Check.That(runner.Failure is AggregateException); Check.Equal(!safety,ReferenceEquals(confirmed,FlightConnectionRecoveryRequiredException.Find(runner.Failure!))); Check.Equal(!safety,FlightJourneyFailure.CanRecoverScene(runner.Failure!));Check.Equal(0,driver.Applies); } } [Test] public async Task ThreatPauseKeepsTypedCauseAndReleasesWithoutAnyFlightInput() { var driver=new FlightDriver();var runner=new NavigationRunner(()=>Sailing with{Navigation=Sailing.Navigation with{ExternalThreat=true}},()=>Now); await runner.RunAsync("Next",driver); Check.Equal(NavigationRunState.Failed,runner.State);Check.That(FlightThreatPauseException.Present(runner.Failure!)); Check.Equal(0,driver.Applies);Check.Equal(0,driver.Selections);Check.Equal(1,driver.Releases); } [Test] public async Task RunnerCannotDriveUnverifiedAdapter() { var driver=new FlightDriver{Ready=false};var runner=new NavigationRunner(()=>Sailing); await runner.RunAsync("Next",driver);Check.Equal(0,driver.Reads);Check.Equal(0,driver.Applies);Check.Equal(NavigationRunState.Failed,runner.State);Check.That(driver.Releases>0); } [Test] public async Task UnconfirmedDestinationPreventsChargingAndFlightControl() { var driver=new FlightDriver{RejectTarget=true};var runner=new NavigationRunner(()=>Sailing,()=>Now); await runner.RunAsync("Next",driver); Check.Equal(1,driver.Selections);Check.Equal(0,driver.Reads);Check.Equal(0,driver.Applies); Check.Equal(NavigationRunState.Failed,runner.State);Check.That(driver.Releases>0); } [Test] public async Task BriefTelemetryGapPausesThenResumesWithoutSelectingTargetAgain() { var player=Sailing;var now=Now;var paused=false;var driver=new FlightDriver(); driver.OnRead=index=> { now=Now.AddMilliseconds(index*200); if(index==1)throw new FlightTelemetryUnavailableException("Brief focus-transition gap"); return new(Frame with{Sequence=index,ObservedAt=now,GameSha256=FlightDriver.Build,Fsd=FsdPhase.Charged,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build}); }; driver.OnApply=decision=>{if(decision.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=>{if(progress.Message.Contains("遥测短暂中断")){paused=true;Check.Equal(0,driver.Applies);Check.That(driver.Releases>0);}}; await runner.RunAsync("Next",driver); Check.That(paused);Check.Equal(1,driver.Selections);Check.Equal(NavigationRunState.Completed,runner.State); } [Test] public async Task InvalidTelemetryProtocolStillStopsImmediately() { var driver=new FlightDriver{OnRead=_=>throw new InvalidDataException("Invalid native frame")}; var runner=new NavigationRunner(()=>Sailing,()=>Now);await runner.RunAsync("Next",driver); Check.Equal(1,driver.Reads);Check.Equal(0,driver.Applies);Check.Equal(NavigationRunState.Failed,runner.State); } [Test] public async Task AGenericDangerMarkerWithoutAnAttackDoesNotEndNavigation() { var player=Sailing with{InDanger=true,Status=new(1L<<22,0,20,1,0,null,null,null,null,[])};var now=Now; var driver=new FlightDriver{OnRead=index=>{now=Now.AddMilliseconds(index*100);return new(Frame with{Sequence=index,ObservedAt=now,GameSha256=FlightDriver.Build, Fsd=FsdPhase.Charged,Forward=new(1,0,0),ThermalDangerExplained=false},Thermal with{GameSha256=FlightDriver.Build});}}; driver.OnApply=decision=>{if(decision.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; var runner=new NavigationRunner(()=>player,()=>now);await runner.RunAsync("Next",driver); Check.That(driver.Applies>0);Check.Equal(NavigationRunState.Completed,runner.State); } [Test] public async Task BriefRepeatedFrameAndExpiredApplyResumeWithOneDestinationLock() { var player=Sailing;var now=Now;var driver=new FlightDriver();var pauses=0; driver.OnRead=index=> { now=Now.AddMilliseconds(index*200); return new(Frame with{Sequence=index==2?1:index,ObservedAt=now,GameSha256=FlightDriver.Build,Fsd=FsdPhase.Charged,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build}); }; driver.OnApply=decision=> { if(driver.Applies==1)throw new FlightTelemetryUnavailableException("Scheduler delayed this input beyond its deadline"); if(decision.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}}; }; var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=>{if(progress.Message.Contains("遥测短暂中断")){pauses++;Check.That(driver.Releases>0);}}; await runner.RunAsync("Next",driver); Check.Equal(2,pauses);Check.Equal(1,driver.Selections);Check.Equal(NavigationRunState.Completed,runner.State); } [Test] public async Task PersistentFrozenTelemetryKeepsRecoveringUntilUserCancels() { var now=Now;var driver=new FlightDriver{OnRead=index=> { now=Now.AddSeconds(index*5); return new(Frame with{Sequence=1,ObservedAt=now,GameSha256=FlightDriver.Build},Thermal with{GameSha256=FlightDriver.Build}); }}; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); var runner=new NavigationRunner(()=>Sailing,()=>now); runner.Progress+=progress=>{if(progress.Message.Contains("遥测短暂中断")&&now-Now>TimeSpan.FromSeconds(30))runner.Stop();}; await runner.RunAsync("Next",driver,timeout.Token); Check.Equal(NavigationRunState.Stopped,runner.State);Check.That(driver.Releases>0);Check.That(driver.Reads>=7&&driver.Reads<10); } [Test] public async Task ExpiredControlFrameIsRecomputedWithoutCancellingTheOwnedCharge() { var player=Sailing;var now=Now;var driver=new FlightDriver();var recovered=false; driver.OnRead=i=>{now=Now.AddMilliseconds(i*200);return new(Frame with{Sequence=i,ObservedAt=now,GameSha256=FlightDriver.Build,Fsd=FsdPhase.Charged,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build});}; driver.OnApply=d=> { Check.Equal(0,driver.Releases); if(driver.Applies==1)throw new FlightTelemetryUnavailableException("expired internal frame 123",recovery:FlightRecovery.ReadNextFrame); if(d.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}}; }; var runner=new NavigationRunner(()=>player,()=>now);runner.Progress+=p=>{if(p.Message.Contains("自动重试"))recovered=true;Check.That(!p.Message.Contains("123"));}; await runner.RunAsync("Next",driver);Check.That(recovered);Check.Equal(1,driver.Selections);Check.Equal(NavigationRunState.Completed,runner.State); } [Test] public async Task PreparationAndPipeFailuresRecoverBeyondOldTwoSecondLimit() { var player=Sailing;var now=Now;var driver=new FlightDriver(); driver.OnPrepare=()=>{now=now.AddSeconds(3);if(driver.Preparations<4)throw new IOException("Temporarily disconnected");}; driver.OnRead=i=>{now=now.AddMilliseconds(200);return new(Frame with{Sequence=i,ObservedAt=now,GameSha256=FlightDriver.Build,Fsd=FsdPhase.Charged,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build});}; driver.OnApply=d=>{if(d.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; var runner=new NavigationRunner(()=>player,()=>now);await runner.RunAsync("Next",driver); Check.Equal(4,driver.Preparations);Check.Equal(1,driver.Selections);Check.Equal(NavigationRunState.Completed,runner.State); } [Test] public async Task LostNativeTargetIsSelectedAgainBeforeAnyInput() { var player=Sailing;var now=Now;var driver=new FlightDriver(); driver.OnRead=i=>{now=now.AddMilliseconds(200);return new(Frame with{Sequence=i,ObservedAt=now,GameSha256=FlightDriver.Build,TargetSystemAddress=i==1?99:2,Fsd=FsdPhase.Charged,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build});}; driver.OnApply=d=>{Check.Equal(2,driver.Selections);if(d.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; var runner=new NavigationRunner(()=>player,()=>now);await runner.RunAsync("Next",driver); Check.Equal(2,driver.Selections);Check.Equal(NavigationRunState.Completed,runner.State); } [Test] public void RecoveryOnlyRetriesRecognisedTimingAndConnectionFailures() { Check.That(NavigationRecovery.CanRetry(new InvalidOperationException("Navigation reference frame expired; read fresh telemetry and retry"),default)); Check.That(NavigationRecovery.CanRetry(new InvalidOperationException("Local target telemetry unavailable or stale"),default)); Check.That(NavigationRecovery.CanRetry(new InvalidOperationException("Native main-ship health unavailable or stale"),default)); Check.That(!NavigationRecovery.CanRetry(new InvalidOperationException("Ship damage protection has stopped flight control"),default)); Check.That(!NavigationRecovery.CanRetry(new InvalidDataException("Invalid frame"),default)); Check.That(!NavigationRecovery.CanRetry(new InvalidOperationException("Another controller owns the flight lease"),default)); using var stop=new CancellationTokenSource();stop.Cancel();Check.That(!NavigationRecovery.CanRetry(new IOException(),stop.Token)); } [Test]public async Task WrappedProtocolAndIdentityFailuresNeverBecomeTransportRecovery() { foreach(var failure in new Exception[]{new IOException("transport wrapper",new InvalidDataException("build mismatch")), new FlightTelemetryUnavailableException("telemetry wrapper",new InvalidDataException("wrong entity")), new AggregateException(new IOException("pipe"),new InvalidDataException("wrong owner"))}) { Check.That(!NavigationRecovery.CanRetry(failure,default));var attempts=0;var recovered=0;var rejected=false; try{await NavigationRecovery.ReadWithRecoveryAsync(_=>{attempts++;throw failure;},()=>recovered++,default,TimeSpan.FromMilliseconds(1));} catch(Exception actual){rejected=ReferenceEquals(actual,failure);} Check.That(rejected);Check.Equal(1,attempts);Check.Equal(0,recovered); } Check.That(NavigationRecovery.CanRetry(new IOException("actual transport interruption"),default)); } [Test] public async Task RunnerConfirmsArrivalThroughJournalInsteadOfElapsedTime() { var player=Sailing;var now=Now;var driver=new FlightDriver();var commits=0; driver.OnRead=index=> { now=Now.AddMilliseconds((index-1)*200); return new(Frame with{Sequence=index,ObservedAt=now,GameSha256=FlightDriver.Build,Fsd=FsdPhase.Charged,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build}); }; driver.OnApply=decision=>{if(decision.PermitJump){commits++;player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}}; var runner=new NavigationRunner(()=>player,()=>now);await runner.RunAsync("Next",driver); Check.Equal(1,commits);Check.Equal(NavigationRunState.Completed,runner.State);Check.That(driver.Releases>0); } [Test] public async Task ChangedRouteAfterTelemetryReadCannotApplyAnyInput() { var player=Sailing;var driver=new FlightDriver(); driver.OnRead=_=> { player=player with{Navigation=player.Navigation with{Route=[new(1,"Here",new(0,0,0),"G"),Next with{SystemAddress=3}]}}; return new(Frame with{GameSha256=FlightDriver.Build},Thermal with{GameSha256=FlightDriver.Build}); }; var runner=new NavigationRunner(()=>player,()=>Now);await runner.RunAsync("Next",driver); Check.Equal(0,driver.Applies);Check.Equal(NavigationRunState.Failed,runner.State);Check.That(driver.Releases>0); } [Test] public async Task FinalHopRouteClearReleasesControlUntilMatchingJournalArrival() { var player=Sailing;var now=Now;var driver=new FlightDriver();var waited=false; driver.OnRead=index=> { now=Now.AddSeconds(8); player=player with{Navigation=player.Navigation with{Route=[],RouteAt=now,JumpInProgress=true,JumpStartedAt=Now,JumpStateAt=Now,JumpTargetSystemAddress=2,JumpTargetSystem="Next"}}; return new(Frame with{Sequence=index,ObservedAt=now,GameSha256=FlightDriver.Build},Thermal with{GameSha256=FlightDriver.Build}); }; var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=> { if(!progress.Message.Contains("等待 Journal 确认抵达"))return; waited=true;Check.That(driver.Releases>0);Check.Equal(0,driver.Applies);now=Now.AddSeconds(18); player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2,CurrentSystemAt=now,LastJumpAt=now,JumpStateAt=now,JumpInProgress=false}}; }; await runner.RunAsync("Next",driver); Check.That(waited);Check.Equal(NavigationRunState.Completed,runner.State);Check.Equal(1,driver.Reads);Check.Equal(0,driver.Applies); } [Test] public async Task FinalHopRouteClearWithoutMatchingFreshStartStopsNavigation() { foreach(var jump in new NavigationJournalState[]{new(),new(){JumpInProgress=true,JumpTargetSystemAddress=3,JumpTargetSystem="Elsewhere",JumpStartedAt=Now},new(){JumpInProgress=true,JumpTargetSystemAddress=2,JumpTargetSystem="Next",JumpStartedAt=Now.AddMinutes(-1)}}) { var player=Sailing;var driver=new FlightDriver{OnRead=index=> { player=player with{Navigation=jump with{Route=[],RouteAt=Now,CurrentSystemAddress=1}}; return new(Frame with{Sequence=index,GameSha256=FlightDriver.Build},Thermal with{GameSha256=FlightDriver.Build}); }}; var runner=new NavigationRunner(()=>player,()=>Now);await runner.RunAsync("Next",driver); Check.Equal(NavigationRunState.Failed,runner.State);Check.Equal(0,driver.Applies);Check.That(driver.Releases>0); } } [Test] public async Task MatchingJournalClockLeadWaitsForArrivalButExcessiveFutureEventsAreRejected() { foreach(var lead in new[]{3,6}) { var player=Sailing;var now=Now;var driver=new FlightDriver();var waited=false; driver.OnRead=index=> { var eventAt=now.AddSeconds(lead); player=player with{Navigation=player.Navigation with{Route=[],RouteAt=eventAt,JumpInProgress=true, JumpStartedAt=eventAt,JumpStateAt=eventAt,JumpTargetSystemAddress=2,JumpTargetSystem="Next"}}; return new(Frame with{Sequence=index,ObservedAt=now,GameSha256=FlightDriver.Build},Thermal with{GameSha256=FlightDriver.Build}); }; var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=> { if(!progress.Message.Contains("等待 Journal 确认抵达"))return; waited=true;Check.Equal(0,driver.Applies);Check.That(driver.Releases>0);now=now.AddSeconds(20); player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2,LastJumpAt=now.AddSeconds(lead),JumpInProgress=false}}; }; await runner.RunAsync("Next",driver); Check.Equal(lead==3?NavigationRunState.Completed:NavigationRunState.Failed,runner.State); Check.Equal(lead==3,waited);Check.Equal(0,driver.Applies);Check.That(driver.Releases>0); } } [Test] public async Task FinalHopArrivalWaitHasDeadlineAndNeverKeepsFlying() { var player=Sailing;var now=Now;var driver=new FlightDriver{OnRead=index=> { player=player with{Navigation=player.Navigation with{Route=[],RouteAt=Now,JumpInProgress=true,JumpStartedAt=Now,JumpStateAt=Now,JumpTargetSystemAddress=2,JumpTargetSystem="Next"}}; return new(Frame with{Sequence=index,GameSha256=FlightDriver.Build},Thermal with{GameSha256=FlightDriver.Build}); }}; var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=>{if(progress.Message.Contains("等待 Journal 确认抵达"))now=Now.AddSeconds(61);}; await runner.RunAsync("Next",driver); Check.Equal(NavigationRunState.Failed,runner.State);Check.Equal(0,driver.Applies);Check.Equal(1,driver.Reads);Check.That(driver.Releases>0); } [Test] public async Task ManualTakeoverStopsRunnerAndReleasesOverrides() { var driver=new FlightDriver{OnRead=_=>new(Frame with{GameSha256=FlightDriver.Build,PlayerOverride=true},Thermal)}; var runner=new NavigationRunner(()=>Sailing,()=>Now);await runner.RunAsync("Next",driver); Check.Equal(0,driver.Applies);Check.Equal(NavigationRunState.Stopped,runner.State);Check.That(driver.Releases>0); } [Test] public async Task FutureFsdTargetBeforeIntermediateArrivalKeepsAcknowledgedLeg() { var middle=Next;var last=new NavWaypoint(3,"Final",new(20,0,0),"K"); var player=Sailing with{Navigation=Sailing.Navigation with{Route=[Sailing.Navigation.Route[0],middle,last]}}; var now=Now;var driver=new FlightDriver();var commits=0;var waited=false; driver.OnRead=index=> { now=now.AddMilliseconds(200);var second=player.Navigation.CurrentSystemAddress==2; return new(Frame with{Sequence=index,ObservedAt=now,GameSha256=FlightDriver.Build,Fsd=FsdPhase.Charged, SystemAddress=second?2:1,TargetSystemAddress=second?3:2,Forward=new(1,0,0)},Thermal with{GameSha256=FlightDriver.Build}); }; driver.OnApply=decision=> { if(!decision.PermitJump)return;commits++; player=commits==1?player with{Navigation=player.Navigation with{TargetSystemAddress=3,TargetSystem="Final",JumpInProgress=true, JumpTargetSystemAddress=2,JumpTargetSystem="Next",JumpStartedAt=now}}: player with{Navigation=player.Navigation with{CurrentSystemAddress=3,LastJumpAt=now}}; }; var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=> { if(!progress.Message.Contains("等待实际抵达"))return; waited=true;Check.Equal(1,commits);Check.That(driver.Releases>0);now=now.AddSeconds(2); player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2,JumpInProgress=false,LastJumpAt=now}}; }; await runner.RunAsync("Final",driver); Check.That(waited);Check.Equal(2,commits);Check.Equal(2,driver.Selections);Check.Equal(NavigationRunState.Completed,runner.State); } }