using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public class TravelPreparationTests { private static PlayerState Player(long flags)=>new(){InGame=true,Status=new(flags,0,32,1,0,null,null,null,null,[]),StatusAt=DateTimeOffset.UtcNow,StatusReadAt=DateTimeOffset.UtcNow}; private sealed class Channel:IGameInputChannel { public GameInputState Frame=new(1,42,DateTimeOffset.UtcNow,true,true,true); public readonly List Writes=[]; public Action? OnWrite; public Action? OnRead; public bool Refresh=true; public int RefusedReads,Reads; public string Refusal="Game input telemetry unavailable or stale"; public Task ConnectAsync(int thread,CancellationToken token)=>Task.CompletedTask; public Task ReadAsync(CancellationToken token){Reads++;OnRead?.Invoke();if(RefusedReads-->0)throw new InvalidOperationException(Refusal);if(Refresh)Frame=Frame with{Sequence=Frame.Sequence+1,ObservedAt=DateTimeOffset.UtcNow};return Task.FromResult(Frame);} public Task SetAsync(GameInputState frame,string input,bool value,CancellationToken token) { Check.That(!value);Writes.Add(input);OnWrite?.Invoke(); Frame=input switch{"hardpoints"=>Frame with{Hardpoints=value},"cargoScoop"=>Frame with{CargoScoop=value},"landingGear"=>Frame with{LandingGear=value},_=>throw new InvalidOperationException()}; Frame=Frame with{AppliedGeneration=Writes.Count};return Task.FromResult((long)Writes.Count); } } [Test]public async Task RetractsEachDeployedInputAndWaitsForPhysicalStatusBeforeReturning() { var c=new Channel();var flags=(1L<<2)|(1L<<6)|(1L<<9);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(4)); var task=TravelPreparation.EnsureRetractedAsync(c,()=>Player(flags),stop.Token,42); while(c.Writes.Count<3)await Task.Delay(10,stop.Token); await Task.Delay(120,stop.Token);Check.That(!task.IsCompleted); flags=0;await task;Check.Equal("hardpoints,cargoScoop,landingGear",string.Join(',',c.Writes)); } [Test]public async Task AlreadyRetractedInputsAreNeverToggled() { var c=new Channel{Frame=new(1,42,DateTimeOffset.UtcNow,false,false,false)}; await TravelPreparation.EnsureRetractedAsync(c,()=>Player(0),CancellationToken.None,42);Check.Equal(0,c.Writes.Count); } [Test]public async Task NewObserverWaitsForFreshInputWithoutSendingAnyToggle() { var c=new Channel{Frame=new(1,42,DateTimeOffset.UtcNow,false,false,false),RefusedReads=2}; using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(2)); await TravelPreparation.EnsureRetractedAsync(c,()=>Player(0),stop.Token,42); Check.That(c.Reads>=4);Check.Equal(0,c.Writes.Count); } [Test]public async Task UnknownInputRefusalIsTerminalAndKnownWaitingCanBeCancelled() { var c=new Channel{RefusedReads=1,Refusal="Input observer ownership changed"}; try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(0),CancellationToken.None,42);throw new Exception("Expected ownership refusal");} catch(InvalidOperationException){Check.Equal(1,c.Reads);Check.Equal(0,c.Writes.Count);} c.RefusedReads=int.MaxValue;c.Refusal="Game input telemetry unavailable or stale"; using var stop=new CancellationTokenSource(150); try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(0),stop.Token,42);throw new Exception("Expected cancellation");} catch(OperationCanceledException){Check.Equal(0,c.Writes.Count);} } [Test]public async Task UnchangedStatusRemainsValidWhenFreshlyReadAndBelongsToTheCurrentSession() { var now=DateTimeOffset.UtcNow; var state=Player(0) with{StatusAt=now.AddMinutes(-5),LoadGameAt=now.AddHours(-1)}; var c=new Channel{Frame=new(1,42,now,false,false,false)}; await TravelPreparation.EnsureRetractedAsync(c,()=>state with{StatusReadAt=DateTimeOffset.UtcNow},CancellationToken.None,42); Check.Equal(0,c.Writes.Count);Check.Equal(now.AddMinutes(-5),state.StatusAt!.Value); Check.That(!TravelPreparation.StatusConfirmed(state with{StatusReadAt=now.AddSeconds(-3)},now)); Check.That(!TravelPreparation.StatusConfirmed(state with{LoadGameAt=now.AddMinutes(-1)},now)); Check.That(!TravelPreparation.StatusConfirmed(state with{StatusAt=now.AddSeconds(6)},now)); } [Test]public async Task MissingSnapshotReadWaitsWithoutWritingAndCanBeCancelled() { var c=new Channel();using var stop=new CancellationTokenSource(150); try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(0) with{StatusReadAt=null},stop.Token,42);throw new Exception("Expected cancellation");} catch(OperationCanceledException){Check.Equal(0,c.Writes.Count);} } [Test]public async Task CancellationDuringRetractionCannotProceedToCharge() { var c=new Channel();using var stop=new CancellationTokenSource();c.OnWrite=stop.Cancel; try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(1L<<6),stop.Token,42);throw new InvalidOperationException("Expected cancellation");} catch(OperationCanceledException){Check.Equal(1,c.Writes.Count);} } [Test]public async Task WrongShipAndStaleInputAreRejectedBeforeWriting() { var c=new Channel(); try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(1L<<6),CancellationToken.None,99);throw new Exception("Expected ship check");}catch(InvalidOperationException){} Check.Equal(0,c.Writes.Count);c.Refresh=false;c.Frame=c.Frame with{ObservedAt=DateTimeOffset.UtcNow.AddSeconds(-1)}; try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(1L<<6),CancellationToken.None,42);throw new Exception("Expected freshness check");}catch(FlightTelemetryUnavailableException){} Check.Equal(0,c.Writes.Count); } private static readonly SystemNavigationTarget Arrival=new(1,10,"Elysian Fields",SystemTargetKind.Station,20); private static SystemFlightFrame Landing=>new(){Sequence=1,ObservedAt=DateTimeOffset.UtcNow,InGame=true,GeometryVerified=true,ShipEntity=42,ReferenceWorld=10, SelectedTarget=Arrival,SystemAddress=1,Mode=LocalFlightMode.Normal,Assists=new(false,true,false),DockingAssist=NativeAssistState.Engaged, Clearance=new(20,DockingPermission.Granted,1,DateTimeOffset.UtcNow,DateTimeOffset.UtcNow.AddMilliseconds(300))}; [Test]public async Task ResumingAnOriginalLandingKeepsItsDeployedGearWithoutWritingAnyInput() { var channel=new Channel();var player=Player(4) with{CommanderId="F1",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddHours(-1),Navigation=new(){CurrentSystemAddress=1}}; var retracted=await TravelPreparation.EnsureRetractedAsync(channel,()=>player,default,42, _=>Task.FromResult(TravelPreparation.PreserveOriginalLanding(Landing,Arrival,DateTimeOffset.UtcNow))); Check.That(!retracted);Check.Equal(0,channel.Reads);Check.Equal(0,channel.Writes.Count); } [Test]public async Task DockingOrOriginalHandoffDuringAnInputReadWinsBeforeAnyRetractionWrite() { foreach(var completes in new[]{false,true}) { var player=Player(4) with{CommanderId="F1",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddHours(-1),Navigation=new(){CurrentSystemAddress=1}}; var landing=false;var channel=new Channel{OnRead=()=>{landing=true;if(completes)player=player with{Docked=true,MarketId=20};}}; var retracted=await TravelPreparation.EnsureRetractedAsync(channel,()=>player,default,42, _=>Task.FromResult(TravelPreparation.AtDockedDestination(player,Arrival)||landing&&TravelPreparation.PreserveOriginalLanding(Landing,Arrival,DateTimeOffset.UtcNow))); Check.That(!retracted);Check.Equal(1,channel.Reads);Check.Equal(0,channel.Writes.Count); } } [Test]public async Task ArrivalYieldCannotHideCancellationOrChangedShipHealthIdentity() { foreach(var cause in new[]{"cancel","commander","ship","session","hull","threat"}) { using var stop=new CancellationTokenSource();var channel=new Channel();var player=Player(4) with{CommanderId="F1",CurrentShipId=7}; try { await TravelPreparation.EnsureRetractedAsync(channel,()=>player,stop.Token,42,async _=> { await Task.Delay(20); if(cause=="cancel")stop.Cancel(); else if(cause=="commander")player=player with{CommanderId="other"}; else if(cause=="ship")player=player with{CurrentShipId=8}; else if(cause=="session")player=player with{LoadGameAt=DateTimeOffset.UtcNow}; else if(cause=="hull")player=player with{Ships=[new(){ShipId=7,HullHealth=0}]}; else player=player with{Navigation=new(){ExternalThreat=true}}; return true; }); throw new Exception("An arrival must not override protection or user cancellation."); } catch(OperationCanceledException)when(cause=="cancel"){} catch(InvalidOperationException)when(cause!="cancel"){} Check.Equal(0,channel.Writes.Count); } } [Test]public void OriginalLandingYieldRequiresFreshSameTargetClearanceAndNoManualOverride() { var frame=Landing;var now=DateTimeOffset.UtcNow;Check.That(TravelPreparation.PreserveOriginalLanding(frame,Arrival,now)); foreach(var invalid in new[]{frame with{PlayerOverride=true},frame with{ExternalThreat=true},frame with{DockingAssist=NativeAssistState.Ready}, frame with{ObservedAt=now.AddSeconds(-1)},frame with{SystemAddress=2},frame with{Clearance=frame.Clearance! with{MarketId=99}}, frame with{Clearance=frame.Clearance! with{ExpiresAt=now.AddMilliseconds(-1)}}}) Check.That(!TravelPreparation.PreserveOriginalLanding(invalid,Arrival,now)); Check.That(!TravelPreparation.AtDockedDestination(Player(4) with{Docked=true,MarketId=99,Navigation=new(){CurrentSystemAddress=1}},Arrival)); } }