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<string> 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<GameInputState> 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<long> 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));
}
}
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<string> 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<GameInputState> 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<long> 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));
}
}