using System.IO.Pipes; using System.Text; using System.Text.Json; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public class NavigationConnectionRecoveryTests { private const string Build="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-23T12:00:00Z"); private static readonly NavWaypoint Next=new(2,"Firbar",new(10,0,0),"TTS"); private static readonly StarEnvelope Star=new(1,"G",Vector3d.Zero,100,115,200); private static PlayerState Sailing=>new(){InGame=true,Navigation=new(){CurrentSystemAddress=1,TargetSystemAddress=2,Route=[new(1,"Here",new(0,0,0),"G"),Next]}}; private static FlightObservation Observation(int sequence,DateTimeOffset now)=>new(new(){Sequence=sequence,ObservedAt=now,GameSha256=Build, LoadoutFingerprint="fit",GeometryVerified=true,InSupercruise=true,Position=new(150,0,0),Forward=new(1,0,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.Charged,CanBeginCharge=true,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=10,ManoeuvreSecondsUpper=0, RefuelSecondsUpper=8,PlannedMinimumStarDistance=120,PlannedMaximumStarDistance=500},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 transition fixture"}); private static object Hello(int id,bool fresh=true,bool verified=true)=>new{ok=true,result=new{schema=1,name="XFEEDToolBox.Navigation",processId=id,gameSha256=Build, verified,continuousPose=fresh,continuousHeat=fresh,starEnvelopes=true,nativeSteering=true,chargeAndCommitInterlock=true,applicationSteeringAxes=true, localCruiseControl=false,localDockingRequests=false,evidence="Isolated build-pinned IPC fixture"}}; private static async Task Serve(int id,object reply,CancellationToken token,Action? request=null,Task? hold=null) { await using var server=new NamedPipeServerStream($"XFEEDToolBox.Navigation.{id}",PipeDirection.InOut,1,PipeTransmissionMode.Byte,PipeOptions.Asynchronous); await server.WaitForConnectionAsync(token);var buffer=new byte[16384];var count=await server.ReadAsync(buffer,token); request?.Invoke(Encoding.UTF8.GetString(buffer,0,count)); if(hold is not null)await hold.WaitAsync(token); await server.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(reply,ToolboxSettings.JsonOptions),token); await server.WriteAsync(new byte[]{(byte)'\n'},token);if(OperatingSystem.IsWindows())server.WaitForPipeDrain(); } private static async Task Rejects(Func action)where T:Exception {try{await action();}catch(T){return;}throw new InvalidOperationException("Expected "+typeof(T).Name);} [Test] public async Task ArrivalFreshnessLossRenewsTheHandshakeWithoutAnyPlanningOrFlightInput() { var id=Random.Shared.Next(200000000,290000000);using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); var requests=new List();var recovered=0; var server=Task.Run(async()=> { await Serve(id,Hello(id,false),timeout.Token,requests.Add); await Serve(id,Hello(id),timeout.Token,requests.Add); }); var driver=new NativeFlightControlDriver(id,Build); var ready=await NavigationRecovery.PreflightWithRecoveryAsync(driver,()=>recovered++,timeout.Token);await server; Check.That(ready.Ready);Check.Equal(1,recovered);Check.Equal(2,requests.Count); foreach(var request in requests)Check.Equal("describe",Check.Json(request).GetProperty("op").GetString()!); Check.That(ready.InterfacesVerified,"Closed local cruise/docking interfaces do not disable verified interstellar navigation."); } [Test] public async Task ReadOnlyPreflightPreservesTheLockedDestinationAndFlightObservation() { var id=Random.Shared.Next(200000000,290000000);using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); var frame=Observation(1,DateTimeOffset.UtcNow).Frame;var requests=new List(); void Remember(string request)=>requests.Add(Check.Json(request)); var server=Task.Run(async()=> { await Serve(id,Hello(id),timeout.Token,Remember); await Serve(id,new{ok=true,result=new{generation=1,selected=true,targetAddress="2"}},timeout.Token,Remember); await Serve(id,new{ok=true,result=new{frame}},timeout.Token,Remember); await Serve(id,Hello(id),timeout.Token,Remember); await Serve(id,new{ok=true,result=new{accepted=true}},timeout.Token,Remember); await Serve(id,new{ok=true,result=new{released=true}},timeout.Token,Remember); }); var driver=new NativeFlightControlDriver(id,Build); await driver.PreflightAsync(timeout.Token);await driver.SelectTargetAsync(Next,timeout.Token);await driver.ReadAsync(timeout.Token); var before=driver.LastObservation;await driver.PreflightAsync(timeout.Token); Check.That(ReferenceEquals(before,driver.LastObservation),"Inspecting capabilities must not clear the current flight frame."); await driver.ApplyAsync(new("Charge",null,1,true,false,false,60,"Synthetic control"),1,Next,DateTimeOffset.UtcNow.AddMilliseconds(250),timeout.Token); await driver.ReleaseAsync();await server; Check.Equal(1,requests.Count(r=>r.GetProperty("op").GetString()=="target.select")); Check.Equal(Next.SystemAddress,requests.Single(r=>r.GetProperty("op").GetString()=="apply").GetProperty("targetSystemAddress").GetInt64()); await driver.BeginNavigationSessionAsync(timeout.Token); Check.That(driver.LastObservation is null,"A new intent must establish a new loadout/pose context, while reconnecting must preserve it."); await Rejects(()=>driver.ApplyAsync(new("Charge",null,1,true,false,false,60,"New intent needs target confirmation"),1,Next,DateTimeOffset.UtcNow.AddMilliseconds(250),timeout.Token)); } [Test] public async Task AConcurrentPendingHandshakeDoesNotEraseAnAlreadyVerifiedConnection() { var id=Random.Shared.Next(200000000,290000000);using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); var requested=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var proceed=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var frame=Observation(1,DateTimeOffset.UtcNow).Frame; var server=Task.Run(async()=> { await Serve(id,Hello(id),timeout.Token); await Serve(id,Hello(id),timeout.Token,_=>requested.SetResult(),proceed.Task); await Serve(id,new{ok=true,result=new{frame}},timeout.Token); }); var driver=new NativeFlightControlDriver(id,Build);await driver.PreflightAsync(timeout.Token); var inspection=driver.PreflightAsync(timeout.Token);await requested.Task.WaitAsync(timeout.Token); var read=driver.ReadAsync(timeout.Token); try{Check.That(!read.IsCompleted,"Reading must wait for the pipe, not fail from a temporary null capability cache.");} finally{proceed.SetResult();} await inspection;Check.Equal(1L,(await read).Frame.Sequence);await server; } [Test] public async Task MissingVerifiedInterlockRemainsTerminalInsteadOfRetryingUnverifiedControls() { var id=Random.Shared.Next(200000000,290000000);using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5)); var server=Serve(id,Hello(id,verified:false),timeout.Token); var driver=new NativeFlightControlDriver(id,Build); await Rejects(()=>NavigationRecovery.PreflightWithRecoveryAsync(driver,null,timeout.Token));await server; await Rejects(async()=>{await driver.ReadAsync(timeout.Token);}); } private sealed class Driver:IFlightControlDriver { public int Inspections,Reads,Selections,Applies,Releases;public Func? OnRead;public Action? OnApply; public Task PreflightAsync(CancellationToken token) {Inspections++;return Task.FromResult(new FlightDriverCapabilities(true,Build,true,true,true,true,true,"Isolated recovery driver"));} public Task SelectTargetAsync(NavWaypoint next,CancellationToken token){Check.Equal(Next.SystemAddress,next.SystemAddress);Selections++;return Task.CompletedTask;} public Task ReadAsync(CancellationToken token)=>Task.FromResult(OnRead!(++Reads)); public Task ApplyAsync(FlightDecision decision,long sequence,NavWaypoint next,DateTimeOffset expires,CancellationToken token) {Check.Equal(Next.SystemAddress,next.SystemAddress);Applies++;OnApply?.Invoke(decision);return Task.CompletedTask;} public Task ReleaseAsync(){Releases++;return Task.CompletedTask;} } [Test] public async Task LostDriverReadinessRecoversAndRelocksTheSameNextHopWithoutReplanning() { var player=Sailing;var now=Now;var recovering=false;var driver=new Driver(); driver.OnRead=sequence=> { now=Now.AddMilliseconds(sequence*200); if(sequence==1)throw new FlightTelemetryUnavailableException("Telemetry stale during describe",recovery:FlightRecovery.RefreshDriver); return Observation(sequence,now); }; driver.OnApply=decision=>{if(decision.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5));var runner=new NavigationRunner(()=>player,()=>now); runner.Progress+=progress=>{if(progress.Message.Contains("恢复导航"))recovering=true;}; await runner.RunAsync(Next.Name,driver,timeout.Token); Check.Equal(NavigationRunState.Completed,runner.State);Check.Equal(2,driver.Inspections);Check.Equal(2,driver.Selections); Check.That(driver.Releases>0);Check.That(recovering);Check.Equal(Next,Sailing.Navigation.Route[^1]);Check.Equal(Next,player.Navigation.Route[^1]); } [Test] public async Task ProlongedRecoverableTelemetryGapKeepsTheOriginalItineraryAndResumes() { var player=Sailing;var now=Now;var driver=new Driver(); driver.OnRead=sequence=> { now=sequence<=8?Now.AddSeconds(sequence*5):Now.AddSeconds(40).AddMilliseconds((sequence-8)*200); if(sequence<=8)throw new FlightTelemetryUnavailableException("Temporary native gap"); return Observation(sequence,now); }; driver.OnApply=decision=>{if(decision.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(6));var runner=new NavigationRunner(()=>player,()=>now); await runner.RunAsync(Next.Name,driver,timeout.Token); Check.Equal(NavigationRunState.Completed,runner.State);Check.That(driver.Reads>8);Check.Equal(1,driver.Selections);Check.Equal(Next,player.Navigation.Route[^1]); } [Test] public async Task UserCanCancelAnOngoingReconnectWithoutAnyInput() { var driver=new Driver{OnRead=_=>throw new FlightTelemetryUnavailableException("Waiting for telemetry")}; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5));var runner=new NavigationRunner(()=>Sailing,()=>Now); runner.Progress+=progress=>{if(progress.Message.Contains("遥测短暂中断"))runner.Stop();}; await runner.RunAsync(Next.Name,driver,timeout.Token); Check.Equal(NavigationRunState.Stopped,runner.State);Check.Equal(0,driver.Applies);Check.That(driver.Releases>0); } [Test] public async Task DelayedSystemOrGeometrySynchronizationRecoversAfterThirtySeconds() { foreach(var badSystem in new[]{true,false}) { var player=Sailing;var now=Now;var driver=new Driver(); driver.OnRead=sequence=> { now=sequence<=8?Now.AddSeconds(sequence*5):Now.AddSeconds(40).AddMilliseconds((sequence-8)*200); var observation=Observation(sequence,now); return sequence<=8?observation with{Frame=observation.Frame with{SystemAddress=badSystem?77:1,GeometryVerified=badSystem}}:observation; }; driver.OnApply=decision=>{Check.That(driver.Reads>8,"Missing system/geometry must never receive controls.");if(decision.PermitJump)player=player with{Navigation=player.Navigation with{CurrentSystemAddress=2}};}; using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(6));var runner=new NavigationRunner(()=>player,()=>now); await runner.RunAsync(Next.Name,driver,timeout.Token); Check.Equal(NavigationRunState.Completed,runner.State);Check.Equal(1,driver.Selections);Check.Equal(Next,player.Navigation.Route[^1]); } } }