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<InvalidOperationException>(()=>NavigationProjector.RemainingRoute(nav.State,"Elsewhere"));
nav.Apply(Check.Json("""{"event":"FSDTarget","SystemAddress":42,"Name":"Elsewhere"}"""));
Check.Throws<InvalidOperationException>(()=>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<InvalidDataException>(()=>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<InvalidOperationException>(()=>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<int,FlightObservation>? OnRead;public Action<FlightDecision>? OnApply;public Action? OnPrepare;public Action? OnRelease;
public Task<FlightDriverCapabilities> 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<FlightObservation> 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<Exception,CancellationToken,Task<Exception>> classify):IFlightJourneyFailureClassifier
{
public int Reads;
public Task<Exception> 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<Exception>(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<Exception>(new SavingSceneRecoveryRequiredException(failure));}
return Task.FromResult<Exception>(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<Exception>(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<int>(_=>{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);
}
}
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<InvalidOperationException>(()=>NavigationProjector.RemainingRoute(nav.State,"Elsewhere"));
nav.Apply(Check.Json("""{"event":"FSDTarget","SystemAddress":42,"Name":"Elsewhere"}"""));
Check.Throws<InvalidOperationException>(()=>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<InvalidDataException>(()=>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<InvalidOperationException>(()=>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<int,FlightObservation>? OnRead;public Action<FlightDecision>? OnApply;public Action? OnPrepare;public Action? OnRelease;
public Task<FlightDriverCapabilities> 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<FlightObservation> 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<Exception,CancellationToken,Task<Exception>> classify):IFlightJourneyFailureClassifier
{
public int Reads;
public Task<Exception> 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<Exception>(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<Exception>(new SavingSceneRecoveryRequiredException(failure));}
return Task.FromResult<Exception>(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<Exception>(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<int>(_=>{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);
}
}