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(),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 Classify(Exception original,FlightConnectionBinding binding,Func> read, Func? 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); } } }