using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class FlightConnectionRecoveryTests
{
private const string Hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static FlightConnectionBinding Binding=>new(74916,639286347139045612,Hash,"F1",8,"Solo",DateTimeOffset.UtcNow.AddMinutes(-20),1200);
private static JsonElement Menu(long sequence,string state="FailedToConnect",bool available=true,DateTimeOffset? at=null)=>JsonSerializer.SerializeToElement(new
{
schema=1,name="XFEEDToolBox.Session",processId=74916,gameSha256=Hash,available,sequence,
observedAtUnixMilliseconds=(at??DateTimeOffset.UtcNow).ToUnixTimeMilliseconds(),state,nextState=state,
actions=Array.Empty<object>(),lastRequestId="",actionApplied=false,actionFailed=false
});
private static JsonElement Navigation(bool live=false,bool pose=false,bool heat=false,string hash=Hash)=>JsonSerializer.SerializeToElement(new
{schema=1,name="XFEEDToolBox.Navigation",processId=74916,gameSha256=hash,verified=true,ready=true,inGame=live,continuousPose=pose,continuousHeat=heat});
private static Exception Exhausted()=>new InvalidOperationException("星系内飞行数据恢复未成功,已停止并交还控制。",new InvalidOperationException("Native player telemetry unavailable or stale"));
private static Task<Exception> Classify(Exception original,FlightConnectionBinding binding,Func<CancellationToken,Task<(JsonElement,JsonElement)>> read,
Func<CancellationToken,Task>? verify=null,CancellationToken token=default,TimeSpan? timeout=null)
=>FlightConnectionRecovery.ClassifyAsync(original,binding,read,verify??(_=>Task.CompletedTask),token,
confirmation:TimeSpan.FromMilliseconds(30),timeout:timeout??TimeSpan.FromSeconds(2),pollInterval:TimeSpan.FromMilliseconds(10));
[Test]public async Task MauveAdderExhaustedLocalRecoveryNeedsFreshAdvancingFailureAndNoLiveCockpit()
{
var original=Exhausted();var reads=0;var checks=0;var binding=Binding;
var result=await Classify(original,binding,_=>Task.FromResult((Menu(++reads),Navigation())),_=>{checks++;return Task.CompletedTask;});
Check.That(result is FlightConnectionRecoveryRequiredException);
var confirmed=(FlightConnectionRecoveryRequiredException)result;
Check.That(ReferenceEquals(original,confirmed.InnerException));Check.Equal(binding,confirmed.Binding);
Check.That(reads>=2&&checks==2*reads);
Check.That(ReferenceEquals(confirmed,FlightConnectionRecoveryRequiredException.Find(new InvalidOperationException("travel failed",confirmed))));
Check.That(!SavingSceneRecoveryRequiredException.Present(result));
Check.That(!NavigationRecovery.CanRetry(result,default)&&!FlightJourneyFailure.HasTransientCause(result,default));
try{new FlightAutomationResult(NavigationRunState.Failed,result).RequireCompleted(default);}
catch(FlightConnectionRecoveryRequiredException observed){Check.That(ReferenceEquals(result,observed));return;}
throw new InvalidOperationException("Positive connection proof did not reach the owning trade entry.");
}
[Test]public async Task LiveFlightLoadingUnknownUnavailableAndWrongBuildNeverRequestAnOfficialRestart()
{
var original=Exhausted();
foreach(var observation in new[]{(Menu(1,"EnteringLocation"),Navigation()),(Menu(1,"Unknown"),Navigation()),
(Menu(1,available:false),Navigation()),(Menu(1),Navigation(live:true)),(Menu(1),Navigation(pose:true)),
(Menu(1),Navigation(heat:true)),(Menu(1),Navigation(hash:new string('b',64))),
(Menu(1,at:DateTimeOffset.UtcNow.AddSeconds(-2)),Navigation())})
{
var reads=0;var result=await Classify(original,Binding,_=>{reads++;return Task.FromResult(observation);});
Check.That(ReferenceEquals(original,result));Check.Equal(1,reads);
}
}
[Test]public async Task ARepeatedPublicationOrBudgetExhaustionCannotBecomeAConnectionProof()
{
var original=Exhausted();var repeated=Menu(1);var reads=0;
var result=await Classify(original,Binding,_=>{reads++;return Task.FromResult((repeated,Navigation()));},timeout:TimeSpan.FromMilliseconds(65));
Check.That(ReferenceEquals(original,result)&&reads>1);
result=await Classify(original,Binding,async token=>{await Task.Delay(10000,token);return(Menu(1),Navigation());},timeout:TimeSpan.FromMilliseconds(20));
Check.That(ReferenceEquals(original,result));
}
[Test]public async Task IdentityHealthManualOwnershipAndCancelledJourneysNeverProbeMenus()
{
foreach(var original in new Exception[]{new InvalidDataException("changed ship"),new FlightHealthPauseException(),new FlightThreatPauseException(),
new InvalidOperationException("玩家已主动接管离站,自动航程已停止。"),new InvalidOperationException("Another controller owns local navigation"),
new DockingHandoffException("failed original handoff",1),new AggregateException(new IOException("transport"),new InvalidDataException("PID changed")),
new AggregateException(new IOException("transport"),new OperationCanceledException("user Stop")),
new IOException("wrapped cancellation",new OperationCanceledException("user Stop"))})
{
var result=await Classify(original,Binding,_=>throw new InvalidOperationException("Must not read a menu for this failure"));
Check.That(ReferenceEquals(original,result));
}
using var stop=new CancellationTokenSource();stop.Cancel();var cancelled=false;
try{await Classify(Exhausted(),Binding,_=>throw new InvalidOperationException("Must not probe after Stop"),token:stop.Token);}
catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public async Task IdentityChangeDuringConsecutiveMenuReadsRemainsTerminal()
{
var checks=0;var rejected=false;
try{await Classify(Exhausted(),Binding,_=>Task.FromResult((Menu(1),Navigation())),_=>
{if(++checks==2)throw new InvalidDataException("process start or commander changed after read");return Task.CompletedTask;});}
catch(InvalidDataException){rejected=true;}
Check.That(rejected);Check.Equal(2,checks);
}
[Test]public async Task StopAfterTheReadStillWinsWhenAReadCallbackIgnoresItsCancellationToken()
{
using var stop=new CancellationTokenSource();var cancelled=false;
try{await Classify(Exhausted(),Binding,_=>{stop.Cancel();return Task.FromResult((Menu(1),Navigation()));},token:stop.Token);}
catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public void APositiveConnectionProofCannotHideAProtectedOrUnknownCleanupFailure()
{
var confirmed=new FlightConnectionRecoveryRequiredException(Binding,Menu(1),Navigation(),Exhausted());
foreach(var protectedFailure in new Exception[]{new InvalidDataException("identity"),new FlightHealthPauseException(),new FlightThreatPauseException(),
new OperationCanceledException("user Stop"),new InvalidOperationException("Another controller owns navigation"),
new InvalidOperationException("wrapped cancellation",new OperationCanceledException("user Stop")),
new DockingHandoffException("handoff",1),new FlightDestinationChangedException("old","new",new IOException("stale"))})
Check.That(FlightConnectionRecoveryRequiredException.Find(new AggregateException(confirmed,protectedFailure)) is null);
Check.That(ReferenceEquals(confirmed,FlightConnectionRecoveryRequiredException.Find(new AggregateException(confirmed,new IOException("transport cleanup")))));
}
[Test]public void RecoveringAFlightKeepsItsProcessCommanderShipModeSessionAndCargoBinding()
{
var binding=Binding;var player=new PlayerState{CommanderId="F1",CurrentShipId=8,GameMode="Solo",LoadGameAt=binding.SessionStartedAt,CargoCount=1200,
Ships=[new(){ShipId=8,HullHealth=1}]};
FlightConnectionRecovery.RequireCurrentIdentity(binding,player,default);
foreach(var changed in new[]{player with{CommanderId="F2"},player with{CurrentShipId=9},player with{GameMode="Open"},
player with{LoadGameAt=binding.SessionStartedAt.AddSeconds(1)},player with{CargoCount=0}})
{
var rejected=false;try{FlightConnectionRecovery.RequireCurrentIdentity(binding,changed,default);}catch(InvalidDataException){rejected=true;}
Check.That(rejected);
}
foreach(var unsafePlayer in new[]{player with{Navigation=player.Navigation with{ExternalThreat=true}},player with{Ships=[new(){ShipId=8,HullHealth=0}]}})
{
var rejected=false;try{FlightConnectionRecovery.RequireCurrentIdentity(binding,unsafePlayer,default);}catch(FlightSafetyPauseException){rejected=true;}
Check.That(rejected);
}
var executable=Path.Combine(Path.GetTempPath(),"game","EliteDangerous64.exe");
var client=new GameSessionTarget(binding.ProcessId,binding.StartedUtcTicks,executable,"launcher",false);
FlightConnectionRecovery.RequireOriginalClient(binding,client,executable);
foreach(var changed in new[]{client with{ProcessId=1},client with{StartedUtcTicks=binding.StartedUtcTicks+1},client with{Executable=executable+".other"}})
{
var rejected=false;try{FlightConnectionRecovery.RequireOriginalClient(binding,changed,executable);}catch(InvalidDataException){rejected=true;}
Check.That(rejected);
}
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class FlightConnectionRecoveryTests
{
private const string Hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static FlightConnectionBinding Binding=>new(74916,639286347139045612,Hash,"F1",8,"Solo",DateTimeOffset.UtcNow.AddMinutes(-20),1200);
private static JsonElement Menu(long sequence,string state="FailedToConnect",bool available=true,DateTimeOffset? at=null)=>JsonSerializer.SerializeToElement(new
{
schema=1,name="XFEEDToolBox.Session",processId=74916,gameSha256=Hash,available,sequence,
observedAtUnixMilliseconds=(at??DateTimeOffset.UtcNow).ToUnixTimeMilliseconds(),state,nextState=state,
actions=Array.Empty<object>(),lastRequestId="",actionApplied=false,actionFailed=false
});
private static JsonElement Navigation(bool live=false,bool pose=false,bool heat=false,string hash=Hash)=>JsonSerializer.SerializeToElement(new
{schema=1,name="XFEEDToolBox.Navigation",processId=74916,gameSha256=hash,verified=true,ready=true,inGame=live,continuousPose=pose,continuousHeat=heat});
private static Exception Exhausted()=>new InvalidOperationException("星系内飞行数据恢复未成功,已停止并交还控制。",new InvalidOperationException("Native player telemetry unavailable or stale"));
private static Task<Exception> Classify(Exception original,FlightConnectionBinding binding,Func<CancellationToken,Task<(JsonElement,JsonElement)>> read,
Func<CancellationToken,Task>? verify=null,CancellationToken token=default,TimeSpan? timeout=null)
=>FlightConnectionRecovery.ClassifyAsync(original,binding,read,verify??(_=>Task.CompletedTask),token,
confirmation:TimeSpan.FromMilliseconds(30),timeout:timeout??TimeSpan.FromSeconds(2),pollInterval:TimeSpan.FromMilliseconds(10));
[Test]public async Task MauveAdderExhaustedLocalRecoveryNeedsFreshAdvancingFailureAndNoLiveCockpit()
{
var original=Exhausted();var reads=0;var checks=0;var binding=Binding;
var result=await Classify(original,binding,_=>Task.FromResult((Menu(++reads),Navigation())),_=>{checks++;return Task.CompletedTask;});
Check.That(result is FlightConnectionRecoveryRequiredException);
var confirmed=(FlightConnectionRecoveryRequiredException)result;
Check.That(ReferenceEquals(original,confirmed.InnerException));Check.Equal(binding,confirmed.Binding);
Check.That(reads>=2&&checks==2*reads);
Check.That(ReferenceEquals(confirmed,FlightConnectionRecoveryRequiredException.Find(new InvalidOperationException("travel failed",confirmed))));
Check.That(!SavingSceneRecoveryRequiredException.Present(result));
Check.That(!NavigationRecovery.CanRetry(result,default)&&!FlightJourneyFailure.HasTransientCause(result,default));
try{new FlightAutomationResult(NavigationRunState.Failed,result).RequireCompleted(default);}
catch(FlightConnectionRecoveryRequiredException observed){Check.That(ReferenceEquals(result,observed));return;}
throw new InvalidOperationException("Positive connection proof did not reach the owning trade entry.");
}
[Test]public async Task LiveFlightLoadingUnknownUnavailableAndWrongBuildNeverRequestAnOfficialRestart()
{
var original=Exhausted();
foreach(var observation in new[]{(Menu(1,"EnteringLocation"),Navigation()),(Menu(1,"Unknown"),Navigation()),
(Menu(1,available:false),Navigation()),(Menu(1),Navigation(live:true)),(Menu(1),Navigation(pose:true)),
(Menu(1),Navigation(heat:true)),(Menu(1),Navigation(hash:new string('b',64))),
(Menu(1,at:DateTimeOffset.UtcNow.AddSeconds(-2)),Navigation())})
{
var reads=0;var result=await Classify(original,Binding,_=>{reads++;return Task.FromResult(observation);});
Check.That(ReferenceEquals(original,result));Check.Equal(1,reads);
}
}
[Test]public async Task ARepeatedPublicationOrBudgetExhaustionCannotBecomeAConnectionProof()
{
var original=Exhausted();var repeated=Menu(1);var reads=0;
var result=await Classify(original,Binding,_=>{reads++;return Task.FromResult((repeated,Navigation()));},timeout:TimeSpan.FromMilliseconds(65));
Check.That(ReferenceEquals(original,result)&&reads>1);
result=await Classify(original,Binding,async token=>{await Task.Delay(10000,token);return(Menu(1),Navigation());},timeout:TimeSpan.FromMilliseconds(20));
Check.That(ReferenceEquals(original,result));
}
[Test]public async Task IdentityHealthManualOwnershipAndCancelledJourneysNeverProbeMenus()
{
foreach(var original in new Exception[]{new InvalidDataException("changed ship"),new FlightHealthPauseException(),new FlightThreatPauseException(),
new InvalidOperationException("玩家已主动接管离站,自动航程已停止。"),new InvalidOperationException("Another controller owns local navigation"),
new DockingHandoffException("failed original handoff",1),new AggregateException(new IOException("transport"),new InvalidDataException("PID changed")),
new AggregateException(new IOException("transport"),new OperationCanceledException("user Stop")),
new IOException("wrapped cancellation",new OperationCanceledException("user Stop"))})
{
var result=await Classify(original,Binding,_=>throw new InvalidOperationException("Must not read a menu for this failure"));
Check.That(ReferenceEquals(original,result));
}
using var stop=new CancellationTokenSource();stop.Cancel();var cancelled=false;
try{await Classify(Exhausted(),Binding,_=>throw new InvalidOperationException("Must not probe after Stop"),token:stop.Token);}
catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public async Task IdentityChangeDuringConsecutiveMenuReadsRemainsTerminal()
{
var checks=0;var rejected=false;
try{await Classify(Exhausted(),Binding,_=>Task.FromResult((Menu(1),Navigation())),_=>
{if(++checks==2)throw new InvalidDataException("process start or commander changed after read");return Task.CompletedTask;});}
catch(InvalidDataException){rejected=true;}
Check.That(rejected);Check.Equal(2,checks);
}
[Test]public async Task StopAfterTheReadStillWinsWhenAReadCallbackIgnoresItsCancellationToken()
{
using var stop=new CancellationTokenSource();var cancelled=false;
try{await Classify(Exhausted(),Binding,_=>{stop.Cancel();return Task.FromResult((Menu(1),Navigation()));},token:stop.Token);}
catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public void APositiveConnectionProofCannotHideAProtectedOrUnknownCleanupFailure()
{
var confirmed=new FlightConnectionRecoveryRequiredException(Binding,Menu(1),Navigation(),Exhausted());
foreach(var protectedFailure in new Exception[]{new InvalidDataException("identity"),new FlightHealthPauseException(),new FlightThreatPauseException(),
new OperationCanceledException("user Stop"),new InvalidOperationException("Another controller owns navigation"),
new InvalidOperationException("wrapped cancellation",new OperationCanceledException("user Stop")),
new DockingHandoffException("handoff",1),new FlightDestinationChangedException("old","new",new IOException("stale"))})
Check.That(FlightConnectionRecoveryRequiredException.Find(new AggregateException(confirmed,protectedFailure)) is null);
Check.That(ReferenceEquals(confirmed,FlightConnectionRecoveryRequiredException.Find(new AggregateException(confirmed,new IOException("transport cleanup")))));
}
[Test]public void RecoveringAFlightKeepsItsProcessCommanderShipModeSessionAndCargoBinding()
{
var binding=Binding;var player=new PlayerState{CommanderId="F1",CurrentShipId=8,GameMode="Solo",LoadGameAt=binding.SessionStartedAt,CargoCount=1200,
Ships=[new(){ShipId=8,HullHealth=1}]};
FlightConnectionRecovery.RequireCurrentIdentity(binding,player,default);
foreach(var changed in new[]{player with{CommanderId="F2"},player with{CurrentShipId=9},player with{GameMode="Open"},
player with{LoadGameAt=binding.SessionStartedAt.AddSeconds(1)},player with{CargoCount=0}})
{
var rejected=false;try{FlightConnectionRecovery.RequireCurrentIdentity(binding,changed,default);}catch(InvalidDataException){rejected=true;}
Check.That(rejected);
}
foreach(var unsafePlayer in new[]{player with{Navigation=player.Navigation with{ExternalThreat=true}},player with{Ships=[new(){ShipId=8,HullHealth=0}]}})
{
var rejected=false;try{FlightConnectionRecovery.RequireCurrentIdentity(binding,unsafePlayer,default);}catch(FlightSafetyPauseException){rejected=true;}
Check.That(rejected);
}
var executable=Path.Combine(Path.GetTempPath(),"game","EliteDangerous64.exe");
var client=new GameSessionTarget(binding.ProcessId,binding.StartedUtcTicks,executable,"launcher",false);
FlightConnectionRecovery.RequireOriginalClient(binding,client,executable);
foreach(var changed in new[]{client with{ProcessId=1},client with{StartedUtcTicks=binding.StartedUtcTicks+1},client with{Executable=executable+".other"}})
{
var rejected=false;try{FlightConnectionRecovery.RequireOriginalClient(binding,changed,executable);}catch(InvalidDataException){rejected=true;}
Check.That(rejected);
}
}
}