XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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);
    }
}