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<string>? 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<T>(Func<Task> 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<string>();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<JsonElement>();
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<InvalidOperationException>(()=>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<InvalidOperationException>(()=>NavigationRecovery.PreflightWithRecoveryAsync(driver,null,timeout.Token));await server;
await Rejects<InvalidOperationException>(async()=>{await driver.ReadAsync(timeout.Token);});
}
private sealed class Driver:IFlightControlDriver
{
public int Inspections,Reads,Selections,Applies,Releases;public Func<int,FlightObservation>? OnRead;public Action<FlightDecision>? OnApply;
public Task<FlightDriverCapabilities> 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<FlightObservation> 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]);
}
}
}
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<string>? 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<T>(Func<Task> 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<string>();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<JsonElement>();
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<InvalidOperationException>(()=>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<InvalidOperationException>(()=>NavigationRecovery.PreflightWithRecoveryAsync(driver,null,timeout.Token));await server;
await Rejects<InvalidOperationException>(async()=>{await driver.ReadAsync(timeout.Token);});
}
private sealed class Driver:IFlightControlDriver
{
public int Inspections,Reads,Selections,Applies,Releases;public Func<int,FlightObservation>? OnRead;public Action<FlightDecision>? OnApply;
public Task<FlightDriverCapabilities> 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<FlightObservation> 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]);
}
}
}