using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class StartupConnectionRecoveryTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow;
private static readonly GameSessionTarget Client=new(10,100,"game","launcher",false);
private static NativeSessionFrame Failure(long sequence=1,double seconds=0)=>new(sequence,Now.AddSeconds(seconds),"FailedToConnect","FailedToConnect",[]);
private static TimeSpan Immediate(int count)=>TimeSpan.Zero;
[Test]public void FailureNeedsAdvancingFreshStableFramesAndNeverGuessesLoadingOrUnknownStates()
{
var observer=new NativeConnectionFailureObserver();Check.That(!observer.Observe(Failure(),Now));
Check.That(!observer.Observe(Failure(),Now.AddMilliseconds(100)));
for(var index=1;index<20;index++)Check.That(!observer.Observe(Failure(index+1,index*.1),Now.AddSeconds(index*.1)));
Check.That(observer.Observe(Failure(21,2),Now.AddSeconds(2)));
foreach(var state in new[]{"EnteringLocation","SaveGameOnEnterLocation","MultiplayerLoad","Unknown",""})
{
var next=new NativeConnectionFailureObserver{ConfirmationPeriod=TimeSpan.FromMilliseconds(1)};
Check.That(!next.Observe(Failure() with{State=state,NextState=state},Now));
Check.That(!next.Observe(Failure(2,.01) with{State=state,NextState=state},Now.AddMilliseconds(10)));
}
foreach(var invalid in new[]{Failure() with{Available=false},Failure() with{NextState="MainMenu"},Failure() with{ObservedAt=Now.AddSeconds(-2)}})
Check.That(!new NativeConnectionFailureObserver{ConfirmationPeriod=TimeSpan.Zero}.Observe(invalid,Now));
}
private static JsonElement Menu(string state="FailedToConnect",bool available=true)=>JsonSerializer.SerializeToElement(new
{schema=1,name="XFEEDToolBox.Session",processId=10,gameSha256="hash",available,sequence=1,observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),state,nextState=state,actions=Array.Empty<string>()});
private static JsonElement Navigation(bool inGame=false,bool pose=false,bool heat=false,string hash="hash")=>JsonSerializer.SerializeToElement(new
{schema=1,name="XFEEDToolBox.Navigation",processId=10,gameSha256=hash,verified=true,inGame,continuousPose=pose,continuousHeat=heat,ready=true});
[Test]public void AnInitializedServiceCanBeFailedWhileAnyLiveFlightOrWrongIdentityRefusesTermination()
{
Check.That(NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),Navigation(),10,"hash",Now));
foreach(var navigation in new[]{Navigation(inGame:true),Navigation(pose:true),Navigation(heat:true),Navigation(hash:"other")})
Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),navigation,10,"hash",Now));
foreach(var menu in new[]{Menu("EnteringLocation"),Menu(available:false),Menu("Unknown")})
Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(menu,Navigation(),10,"hash",Now));
Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),Navigation(),10,"hash",Now.AddSeconds(1)));
}
[Test]public void ALoadGameEventAndOldCargoLocationNeverProveTheNewCockpitReady()
{
var frame=new NativeSessionFrame(1,Now,"InGame","InGame",[]);
var player=new PlayerState{InGame=true,LoadGameAt=Now,LocationAt=Now.AddMinutes(-1),CargoAt=Now.AddMinutes(-1),CargoReliable=true,SystemName="Old station",Navigation=new(){CurrentSystemAddress=42}};
Check.That(!NativeSessionStartupReadiness.Ready(player,frame,Now,Now.AddMinutes(-1)));
player=player with{LocationAt=Now,CargoAt=Now};Check.That(NativeSessionStartupReadiness.Ready(player,frame,Now,Now.AddMinutes(-1)));
Check.That(!NativeSessionStartupReadiness.Ready(player,Failure(),Now,Now.AddMinutes(-1)));
Check.That(!NativeSessionStartupReadiness.Ready(player with{CargoReliable=false},frame,Now));
}
[Test]public async Task OwnedConnectionFailureRetriesTheWorkflowWithoutReplayingAnyOtherException()
{
var attempts=0;var recoveries=0;
await StartupConnectionRecovery.RunAsync((own,token)=>
{own(Client);if(++attempts==1)throw new NativeSessionConnectionFailedException(Failure(),Client);return Task.CompletedTask;},
(client,error,owned,count,token)=>{Check.That(owned&&client==Client&&count==1);recoveries++;return Task.CompletedTask;},default,retryDelay:Immediate);
Check.Equal(2,attempts);Check.Equal(1,recoveries);
foreach(var error in new Exception[]{new IOException("unknown I/O"),new TimeoutException("normal loading"),new InvalidDataException("identity changed")})
{
var count=0;try{await StartupConnectionRecovery.RunAsync((own,token)=>{count++;own(Client);throw error;},
(_,_,_,_,_)=>throw new Exception("Must not recover"),default,retryDelay:Immediate);}catch(Exception caught){Check.That(ReferenceEquals(error,caught));}
Check.Equal(1,count);
}
}
[Test]public async Task ExistingClientsNeedExplicitAdoptionAndTheRecoveryBudgetIsFinite()
{
var attempts=0;var recoveries=0;
try{await StartupConnectionRecovery.RunAsync((_,_)=>{attempts++;throw new NativeSessionConnectionFailedException(Failure(),Client);},
(_,_,_,_,_)=>{recoveries++;return Task.CompletedTask;},default,retryDelay:Immediate);}catch(NativeSessionConnectionFailedException){}
Check.Equal(1,attempts);Check.Equal(0,recoveries);attempts=0;
try{await StartupConnectionRecovery.RunAsync((_,_)=>{attempts++;throw new NativeSessionConnectionFailedException(Failure(),Client);},
(_,_,owned,_,_)=>{Check.That(!owned);recoveries++;return Task.CompletedTask;},default,allowVerifiedExisting:true,retryDelay:Immediate);}catch(StartupConnectionRecoveryExhaustedException){}
Check.Equal(3,attempts);Check.Equal(2,recoveries);
}
[Test]public async Task CancellationAndTheGlobalDeadlineAlwaysWinBeforeAnotherRestart()
{
using var stop=new CancellationTokenSource();var recoveries=0;
try{await StartupConnectionRecovery.RunAsync((own,_)=>{own(Client);stop.Cancel();throw new NativeSessionConnectionFailedException(Failure(),Client);},
(_,_,_,_,_)=>{recoveries++;return Task.CompletedTask;},stop.Token,retryDelay:Immediate);}catch(NativeSessionConnectionFailedException){}
Check.Equal(0,recoveries);
var timed=false;
try{await StartupConnectionRecovery.RunAsync(async (_,token)=>await Task.Delay(10000,token),(_,_,_,_,_)=>Task.CompletedTask,
default,totalTimeout:TimeSpan.FromMilliseconds(20));}catch(TimeoutException){timed=true;}
Check.That(timed);
}
[Test]public async Task LongRunningSupervisionOnlyWaitsForAnExhaustedTypedConnectionFailure()
{
var attempts=0;var waits=0;
await StartupConnectionRecovery.SuperviseAsync(_=>
{if(++attempts==1)throw new StartupConnectionRecoveryExhaustedException(new(Failure(),Client),2);return Task.CompletedTask;},
(_,count,_)=>{waits++;Check.Equal(1,count);return Task.CompletedTask;},default);
Check.Equal(2,attempts);Check.Equal(1,waits);
foreach(var error in new Exception[]{new TimeoutException("unknown loading"),new InvalidDataException("identity"),new NativeSessionConnectionFailedException(Failure())})
{
try{await StartupConnectionRecovery.SuperviseAsync(_=>throw error,(_,_,_)=>throw new Exception("Must not supervise this failure"),default);}
catch(Exception caught){Check.That(ReferenceEquals(caught,error));}
}
using var stop=new CancellationTokenSource();var cancelled=false;
try{await StartupConnectionRecovery.SuperviseAsync(_=>throw new StartupConnectionRecoveryExhaustedException(new(Failure(),Client),2),
async(_,_,token)=>{stop.Cancel();await Task.Delay(1000,token);},stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public async Task AChangedMenuIdentityOrCancellationAfterEvidenceWritesNeverTerminatesTheClient()
{
foreach(var cause in new[]{"changed-menu","identity","cancel","unchanged"})
{
using var stop=new CancellationTokenSource();var terminations=0;var rejected=false;
try
{
await StartupConnectionRecovery.TerminateWithFreshEvidenceAsync(_=>
{
if(cause=="identity")throw new InvalidDataException("PID/start/hash changed");
if(cause=="cancel")stop.Cancel();
return Task.FromResult(cause!="changed-menu");
},_=>{terminations++;return Task.CompletedTask;},stop.Token);
}
catch(InvalidDataException)when(cause is "changed-menu" or "identity"){rejected=true;}
catch(OperationCanceledException)when(cause=="cancel"){rejected=true;}
Check.Equal(cause=="unchanged"?1:0,terminations);Check.Equal(cause!="unchanged",rejected);
}
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class StartupConnectionRecoveryTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow;
private static readonly GameSessionTarget Client=new(10,100,"game","launcher",false);
private static NativeSessionFrame Failure(long sequence=1,double seconds=0)=>new(sequence,Now.AddSeconds(seconds),"FailedToConnect","FailedToConnect",[]);
private static TimeSpan Immediate(int count)=>TimeSpan.Zero;
[Test]public void FailureNeedsAdvancingFreshStableFramesAndNeverGuessesLoadingOrUnknownStates()
{
var observer=new NativeConnectionFailureObserver();Check.That(!observer.Observe(Failure(),Now));
Check.That(!observer.Observe(Failure(),Now.AddMilliseconds(100)));
for(var index=1;index<20;index++)Check.That(!observer.Observe(Failure(index+1,index*.1),Now.AddSeconds(index*.1)));
Check.That(observer.Observe(Failure(21,2),Now.AddSeconds(2)));
foreach(var state in new[]{"EnteringLocation","SaveGameOnEnterLocation","MultiplayerLoad","Unknown",""})
{
var next=new NativeConnectionFailureObserver{ConfirmationPeriod=TimeSpan.FromMilliseconds(1)};
Check.That(!next.Observe(Failure() with{State=state,NextState=state},Now));
Check.That(!next.Observe(Failure(2,.01) with{State=state,NextState=state},Now.AddMilliseconds(10)));
}
foreach(var invalid in new[]{Failure() with{Available=false},Failure() with{NextState="MainMenu"},Failure() with{ObservedAt=Now.AddSeconds(-2)}})
Check.That(!new NativeConnectionFailureObserver{ConfirmationPeriod=TimeSpan.Zero}.Observe(invalid,Now));
}
private static JsonElement Menu(string state="FailedToConnect",bool available=true)=>JsonSerializer.SerializeToElement(new
{schema=1,name="XFEEDToolBox.Session",processId=10,gameSha256="hash",available,sequence=1,observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),state,nextState=state,actions=Array.Empty<string>()});
private static JsonElement Navigation(bool inGame=false,bool pose=false,bool heat=false,string hash="hash")=>JsonSerializer.SerializeToElement(new
{schema=1,name="XFEEDToolBox.Navigation",processId=10,gameSha256=hash,verified=true,inGame,continuousPose=pose,continuousHeat=heat,ready=true});
[Test]public void AnInitializedServiceCanBeFailedWhileAnyLiveFlightOrWrongIdentityRefusesTermination()
{
Check.That(NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),Navigation(),10,"hash",Now));
foreach(var navigation in new[]{Navigation(inGame:true),Navigation(pose:true),Navigation(heat:true),Navigation(hash:"other")})
Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),navigation,10,"hash",Now));
foreach(var menu in new[]{Menu("EnteringLocation"),Menu(available:false),Menu("Unknown")})
Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(menu,Navigation(),10,"hash",Now));
Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),Navigation(),10,"hash",Now.AddSeconds(1)));
}
[Test]public void ALoadGameEventAndOldCargoLocationNeverProveTheNewCockpitReady()
{
var frame=new NativeSessionFrame(1,Now,"InGame","InGame",[]);
var player=new PlayerState{InGame=true,LoadGameAt=Now,LocationAt=Now.AddMinutes(-1),CargoAt=Now.AddMinutes(-1),CargoReliable=true,SystemName="Old station",Navigation=new(){CurrentSystemAddress=42}};
Check.That(!NativeSessionStartupReadiness.Ready(player,frame,Now,Now.AddMinutes(-1)));
player=player with{LocationAt=Now,CargoAt=Now};Check.That(NativeSessionStartupReadiness.Ready(player,frame,Now,Now.AddMinutes(-1)));
Check.That(!NativeSessionStartupReadiness.Ready(player,Failure(),Now,Now.AddMinutes(-1)));
Check.That(!NativeSessionStartupReadiness.Ready(player with{CargoReliable=false},frame,Now));
}
[Test]public async Task OwnedConnectionFailureRetriesTheWorkflowWithoutReplayingAnyOtherException()
{
var attempts=0;var recoveries=0;
await StartupConnectionRecovery.RunAsync((own,token)=>
{own(Client);if(++attempts==1)throw new NativeSessionConnectionFailedException(Failure(),Client);return Task.CompletedTask;},
(client,error,owned,count,token)=>{Check.That(owned&&client==Client&&count==1);recoveries++;return Task.CompletedTask;},default,retryDelay:Immediate);
Check.Equal(2,attempts);Check.Equal(1,recoveries);
foreach(var error in new Exception[]{new IOException("unknown I/O"),new TimeoutException("normal loading"),new InvalidDataException("identity changed")})
{
var count=0;try{await StartupConnectionRecovery.RunAsync((own,token)=>{count++;own(Client);throw error;},
(_,_,_,_,_)=>throw new Exception("Must not recover"),default,retryDelay:Immediate);}catch(Exception caught){Check.That(ReferenceEquals(error,caught));}
Check.Equal(1,count);
}
}
[Test]public async Task ExistingClientsNeedExplicitAdoptionAndTheRecoveryBudgetIsFinite()
{
var attempts=0;var recoveries=0;
try{await StartupConnectionRecovery.RunAsync((_,_)=>{attempts++;throw new NativeSessionConnectionFailedException(Failure(),Client);},
(_,_,_,_,_)=>{recoveries++;return Task.CompletedTask;},default,retryDelay:Immediate);}catch(NativeSessionConnectionFailedException){}
Check.Equal(1,attempts);Check.Equal(0,recoveries);attempts=0;
try{await StartupConnectionRecovery.RunAsync((_,_)=>{attempts++;throw new NativeSessionConnectionFailedException(Failure(),Client);},
(_,_,owned,_,_)=>{Check.That(!owned);recoveries++;return Task.CompletedTask;},default,allowVerifiedExisting:true,retryDelay:Immediate);}catch(StartupConnectionRecoveryExhaustedException){}
Check.Equal(3,attempts);Check.Equal(2,recoveries);
}
[Test]public async Task CancellationAndTheGlobalDeadlineAlwaysWinBeforeAnotherRestart()
{
using var stop=new CancellationTokenSource();var recoveries=0;
try{await StartupConnectionRecovery.RunAsync((own,_)=>{own(Client);stop.Cancel();throw new NativeSessionConnectionFailedException(Failure(),Client);},
(_,_,_,_,_)=>{recoveries++;return Task.CompletedTask;},stop.Token,retryDelay:Immediate);}catch(NativeSessionConnectionFailedException){}
Check.Equal(0,recoveries);
var timed=false;
try{await StartupConnectionRecovery.RunAsync(async (_,token)=>await Task.Delay(10000,token),(_,_,_,_,_)=>Task.CompletedTask,
default,totalTimeout:TimeSpan.FromMilliseconds(20));}catch(TimeoutException){timed=true;}
Check.That(timed);
}
[Test]public async Task LongRunningSupervisionOnlyWaitsForAnExhaustedTypedConnectionFailure()
{
var attempts=0;var waits=0;
await StartupConnectionRecovery.SuperviseAsync(_=>
{if(++attempts==1)throw new StartupConnectionRecoveryExhaustedException(new(Failure(),Client),2);return Task.CompletedTask;},
(_,count,_)=>{waits++;Check.Equal(1,count);return Task.CompletedTask;},default);
Check.Equal(2,attempts);Check.Equal(1,waits);
foreach(var error in new Exception[]{new TimeoutException("unknown loading"),new InvalidDataException("identity"),new NativeSessionConnectionFailedException(Failure())})
{
try{await StartupConnectionRecovery.SuperviseAsync(_=>throw error,(_,_,_)=>throw new Exception("Must not supervise this failure"),default);}
catch(Exception caught){Check.That(ReferenceEquals(caught,error));}
}
using var stop=new CancellationTokenSource();var cancelled=false;
try{await StartupConnectionRecovery.SuperviseAsync(_=>throw new StartupConnectionRecoveryExhaustedException(new(Failure(),Client),2),
async(_,_,token)=>{stop.Cancel();await Task.Delay(1000,token);},stop.Token);}catch(OperationCanceledException){cancelled=true;}
Check.That(cancelled);
}
[Test]public async Task AChangedMenuIdentityOrCancellationAfterEvidenceWritesNeverTerminatesTheClient()
{
foreach(var cause in new[]{"changed-menu","identity","cancel","unchanged"})
{
using var stop=new CancellationTokenSource();var terminations=0;var rejected=false;
try
{
await StartupConnectionRecovery.TerminateWithFreshEvidenceAsync(_=>
{
if(cause=="identity")throw new InvalidDataException("PID/start/hash changed");
if(cause=="cancel")stop.Cancel();
return Task.FromResult(cause!="changed-menu");
},_=>{terminations++;return Task.CompletedTask;},stop.Token);
}
catch(InvalidDataException)when(cause is "changed-menu" or "identity"){rejected=true;}
catch(OperationCanceledException)when(cause=="cancel"){rejected=true;}
Check.Equal(cause=="unchanged"?1:0,terminations);Check.Equal(cause!="unchanged",rejected);
}
}
}