using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class NativeSessionRecoveryTests
{
private sealed class World
{
internal string State="InGame",Last="",Mode="Solo";
internal bool Applied,Fail,LostReply,DoNotApply,Disabled,Duplicate,Stale;
internal int ReadFailures;
internal TimeSpan PrepareDelay,EntryBeginDelay;
internal CancellationTokenSource? CancelAtPause;
internal List<string> Calls=[];
internal readonly DateTimeOffset Loaded=DateTimeOffset.UtcNow.AddMinutes(-1);
internal PlayerState Player=>new(){InGame=State=="InGame",CommanderId="F123",GameMode=Mode,LoadGameAt=Calls.Any(a=>a.StartsWith("SetupPlay"))?Loaded.AddSeconds(1):Loaded};
}
private sealed class Channel(World world,bool entry):INativeRelogChannel
{
internal bool Begun,Ended;
public async Task BeginAsync(string owner,CancellationToken token){Begun=true;if(entry&&world.EntryBeginDelay>TimeSpan.Zero)await Task.Delay(world.EntryBeginDelay,token);}
public Task EndAsync(string owner){Ended=true;return Task.CompletedTask;}
public Task<NativeSessionFrame> ReadAsync(string owner,CancellationToken token)
{
if(world.ReadFailures-->0)throw new IOException("temporary read loss");
NativeSessionMenuAction[] actions=world.State switch{"MainMenu"=>[new("ContinueMenu",true)],"ContinueMenu"=>[new("SetupPlaySolo",true),new("SetupPlayWithEveryone",true)],_=>[]};
return Task.FromResult(new NativeSessionFrame(1,DateTimeOffset.UtcNow.AddSeconds(world.Stale?-10:0),world.State,world.State,actions,world.Last,world.Applied));
}
public Task SubmitAsync(string owner,string requestId,NativeSessionFrame frame,string action,CancellationToken token)
{
if(!entry&&action!="@pause")throw new InvalidOperationException("pause bridge selected an exit");
if(entry&&action is not("ContinueMenu" or "SetupPlaySolo" or "SetupPlayWithEveryone"))throw new InvalidOperationException("entry bridge selected an exit");
world.Calls.Add(action);world.Last=requestId;world.Applied=true;
world.State=action switch{"@pause"=>"Pause","ContinueMenu"=>"ContinueMenu",_=>"InGame"};
if(action=="@pause")world.CancelAtPause?.Cancel();
if(world.LostReply)throw new IOException("reply lost after applied selection");
return Task.CompletedTask;
}
}
private sealed class Recovery(World world):IOriginalSessionMenuRecovery
{
public Task PrepareAsync(CancellationToken token)=>world.PrepareDelay>TimeSpan.Zero?Task.Delay(world.PrepareDelay,token):Task.CompletedTask;
public Task<JsonElement> ReadAsync(CancellationToken token)
{
if(world.ReadFailures-->0)throw new IOException("temporary read loss");
var action=world.State=="Pause"?"ExitOptions":"Confirmed Quit To Main Menu";
var actions=new List<object>{new{name=action,enabled=!world.Disabled,recoveryAction=true},new{name="Confirmed Quit Game",enabled=true,recoveryAction=false}};
if(world.Duplicate)actions.Add(new{name=action,enabled=true,recoveryAction=true});
return Task.FromResult(JsonSerializer.SerializeToElement(new{state=world.State,nextState=world.State,menuReady=true,menuSequence=1,observedAtUnixMilliseconds=DateTimeOffset.UtcNow.AddSeconds(world.Stale?-10:0).ToUnixTimeMilliseconds(),actions,requestId=world.Last,applied=world.Applied,failed=world.Fail}));
}
public Task LeaveAsync(string id,string action,ulong sequence,CancellationToken token)
{
token.ThrowIfCancellationRequested();world.Calls.Add(action);world.Last=id;world.Applied=!world.DoNotApply;
if(!world.DoNotApply)world.State=action=="ExitOptions"?"ExitOptions":"MainMenu";
if(world.LostReply)throw new IOException("reply lost after original action");
return Task.CompletedTask;
}
}
private static (NativeSessionRecoveryChannel Bridge,Channel Pause,Channel Entry) Bridge(World world)
{
var pause=new Channel(world,false);var entry=new Channel(world,true);
return(new(pause,entry,new Recovery(world)){PollInterval=TimeSpan.FromMilliseconds(1),StepTimeout=TimeSpan.FromMilliseconds(80)},pause,entry);
}
private static async Task Fails<T>(Func<Task> run)where T:Exception
{try{await run();}catch(T){return;}throw new InvalidOperationException("Expected "+typeof(T).Name);}
[Test]public async Task ExistingMainMenuDoesNotPauseOrSelectAnotherExit()
{
var world=new World{State="MainMenu",ReadFailures=1};var c=Bridge(world);
await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
Check.That(c.Entry.Begun&&c.Entry.Ended&&!c.Pause.Begun);Check.Equal(0,world.Calls.Count);
}
[Test]public async Task ExistingPauseSubmenuResumesOnlyItsOriginalExitActions()
{
foreach(var state in new[]{"Pause","ExitOptions"})
{
var world=new World{State=state};var c=Bridge(world);
await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
Check.That(c.Entry.Begun&&c.Entry.Ended&&!c.Pause.Begun);Check.That(!world.Calls.Contains("@pause"));
Check.Equal("MainMenu",world.State);Check.Equal("Confirmed Quit To Main Menu",world.Calls.Last());
}
}
[Test]public async Task VerifiedExtensionLeavesToMenuAndEntryOnlyRestoresSolo()
{
var world=new World();var c=Bridge(world);var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){PollInterval=TimeSpan.FromMilliseconds(1),Timeout=TimeSpan.FromSeconds(1)};
await flow.RunAsync();Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&c.Entry.Ended);
}
[Test]public async Task OpenModeIsPreservedAfterTheActualMainMenu()
{
var world=new World{Mode="Open"};var c=Bridge(world);await new NativeRelogWorkflow(c.Bridge,()=>world.Player){PollInterval=TimeSpan.FromMilliseconds(1)}.RunAsync();
Check.Equal("SetupPlayWithEveryone",world.Calls.Last());Check.That(!world.Calls.Contains("SetupPlaySolo"));
}
[Test]public async Task LostRepliesUseObservedOutcomeWithoutRepeatingSelections()
{
var world=new World{LostReply=true,ReadFailures=2};var c=Bridge(world);await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu",string.Join(',',world.Calls));Check.That(c.Entry.Begun&&c.Entry.Ended&&c.Pause.Ended);
}
[Test]public async Task AnUnknownExitOutcomeNeverReplaysOrStartsEntry()
{
var world=new World{DoNotApply=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));await c.Bridge.EndAsync("owner");
Check.Equal("@pause,ExitOptions",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&!c.Entry.Begun);
}
[Test]public async Task DisabledAndAmbiguousExitOptionsFailBeforeAnyExit()
{
foreach(var world in new[]{new World{Disabled=true},new World{Duplicate=true}}){var c=Bridge(world);await Fails<InvalidDataException>(()=>c.Bridge.BeginAsync("owner",default));Check.Equal("@pause",string.Join(',',world.Calls));Check.That(!c.Entry.Begun&&c.Pause.Ended);}
}
[Test]public async Task StaleNativeFramesCannotOpenThePauseMenu()
{
var world=new World{Stale=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));Check.That(world.Calls.Count==0&&c.Pause.Ended&&!c.Entry.Begun);
}
[Test]public async Task CancellationCleansUpPauseWithoutSelectingAnExit()
{
using var stop=new CancellationTokenSource();var world=new World{CancelAtPause=stop};var c=Bridge(world);await Fails<OperationCanceledException>(()=>c.Bridge.BeginAsync("owner",stop.Token));await c.Bridge.EndAsync("owner");
Check.Equal("@pause",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&!c.Entry.Begun);
}
[Test]public async Task ADefinitiveFailedReceiptStopsWithoutEntering()
{
var world=new World{DoNotApply=true,Fail=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));Check.Equal("@pause,ExitOptions",string.Join(',',world.Calls));Check.That(!c.Entry.Begun);
}
[Test]public async Task ExitDeadlineIncludesTheWholeNativeBeginPreparation()
{
var world=new World{PrepareDelay=TimeSpan.FromSeconds(2)};var c=Bridge(world);
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromMilliseconds(30),Timeout=TimeSpan.FromSeconds(1),PollInterval=TimeSpan.FromMilliseconds(1)};
var watch=System.Diagnostics.Stopwatch.StartNew();await Fails<NativeMenuExitTimeoutException>(()=>flow.RunAsync());
Check.That(watch.Elapsed<TimeSpan.FromMilliseconds(500));Check.That(!flow.ReachedMainMenu&&!c.Pause.Begun&&!c.Entry.Begun);Check.Equal(0,world.Calls.Count);
}
[Test]public async Task AConfirmedMenuInsideBeginCancelsTheExitDeadlineBeforeSlowEntrySetup()
{
var world=new World{EntryBeginDelay=TimeSpan.FromMilliseconds(100)};var c=Bridge(world);var callbacks=0;
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromMilliseconds(25),Timeout=TimeSpan.FromSeconds(1),PollInterval=TimeSpan.FromMilliseconds(1),OnReachedMainMenu=()=>{callbacks++;return Task.CompletedTask;}};
await flow.RunAsync();Check.That(flow.ReachedMainMenu&&c.Bridge.ReachedMainMenu);Check.Equal(1,callbacks);
Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo",string.Join(',',world.Calls));
}
[Test]public async Task ExternalCancellationDuringBeginIsNotAnExitTimeoutOrRestartPermission()
{
using var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(20));var world=new World{PrepareDelay=TimeSpan.FromSeconds(2)};var c=Bridge(world);
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromSeconds(1),Timeout=TimeSpan.FromSeconds(2),PollInterval=TimeSpan.FromMilliseconds(1)};
await Fails<OperationCanceledException>(()=>flow.RunAsync(stop.Token));Check.That(!NativeMenuExitSafety.CanFallback(new OperationCanceledException(),stop.Token));Check.Equal(0,world.Calls.Count);
}
[Test]public async Task AProtocolFailureDuringBeginIsNotConvertedIntoAnExitTimeout()
{
var world=new World{Duplicate=true};var c=Bridge(world);
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromMilliseconds(50),Timeout=TimeSpan.FromSeconds(1),PollInterval=TimeSpan.FromMilliseconds(1)};
await Fails<InvalidDataException>(()=>flow.RunAsync());Check.That(!flow.ReachedMainMenu);Check.Equal("@pause",string.Join(',',world.Calls));
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class NativeSessionRecoveryTests
{
private sealed class World
{
internal string State="InGame",Last="",Mode="Solo";
internal bool Applied,Fail,LostReply,DoNotApply,Disabled,Duplicate,Stale;
internal int ReadFailures;
internal TimeSpan PrepareDelay,EntryBeginDelay;
internal CancellationTokenSource? CancelAtPause;
internal List<string> Calls=[];
internal readonly DateTimeOffset Loaded=DateTimeOffset.UtcNow.AddMinutes(-1);
internal PlayerState Player=>new(){InGame=State=="InGame",CommanderId="F123",GameMode=Mode,LoadGameAt=Calls.Any(a=>a.StartsWith("SetupPlay"))?Loaded.AddSeconds(1):Loaded};
}
private sealed class Channel(World world,bool entry):INativeRelogChannel
{
internal bool Begun,Ended;
public async Task BeginAsync(string owner,CancellationToken token){Begun=true;if(entry&&world.EntryBeginDelay>TimeSpan.Zero)await Task.Delay(world.EntryBeginDelay,token);}
public Task EndAsync(string owner){Ended=true;return Task.CompletedTask;}
public Task<NativeSessionFrame> ReadAsync(string owner,CancellationToken token)
{
if(world.ReadFailures-->0)throw new IOException("temporary read loss");
NativeSessionMenuAction[] actions=world.State switch{"MainMenu"=>[new("ContinueMenu",true)],"ContinueMenu"=>[new("SetupPlaySolo",true),new("SetupPlayWithEveryone",true)],_=>[]};
return Task.FromResult(new NativeSessionFrame(1,DateTimeOffset.UtcNow.AddSeconds(world.Stale?-10:0),world.State,world.State,actions,world.Last,world.Applied));
}
public Task SubmitAsync(string owner,string requestId,NativeSessionFrame frame,string action,CancellationToken token)
{
if(!entry&&action!="@pause")throw new InvalidOperationException("pause bridge selected an exit");
if(entry&&action is not("ContinueMenu" or "SetupPlaySolo" or "SetupPlayWithEveryone"))throw new InvalidOperationException("entry bridge selected an exit");
world.Calls.Add(action);world.Last=requestId;world.Applied=true;
world.State=action switch{"@pause"=>"Pause","ContinueMenu"=>"ContinueMenu",_=>"InGame"};
if(action=="@pause")world.CancelAtPause?.Cancel();
if(world.LostReply)throw new IOException("reply lost after applied selection");
return Task.CompletedTask;
}
}
private sealed class Recovery(World world):IOriginalSessionMenuRecovery
{
public Task PrepareAsync(CancellationToken token)=>world.PrepareDelay>TimeSpan.Zero?Task.Delay(world.PrepareDelay,token):Task.CompletedTask;
public Task<JsonElement> ReadAsync(CancellationToken token)
{
if(world.ReadFailures-->0)throw new IOException("temporary read loss");
var action=world.State=="Pause"?"ExitOptions":"Confirmed Quit To Main Menu";
var actions=new List<object>{new{name=action,enabled=!world.Disabled,recoveryAction=true},new{name="Confirmed Quit Game",enabled=true,recoveryAction=false}};
if(world.Duplicate)actions.Add(new{name=action,enabled=true,recoveryAction=true});
return Task.FromResult(JsonSerializer.SerializeToElement(new{state=world.State,nextState=world.State,menuReady=true,menuSequence=1,observedAtUnixMilliseconds=DateTimeOffset.UtcNow.AddSeconds(world.Stale?-10:0).ToUnixTimeMilliseconds(),actions,requestId=world.Last,applied=world.Applied,failed=world.Fail}));
}
public Task LeaveAsync(string id,string action,ulong sequence,CancellationToken token)
{
token.ThrowIfCancellationRequested();world.Calls.Add(action);world.Last=id;world.Applied=!world.DoNotApply;
if(!world.DoNotApply)world.State=action=="ExitOptions"?"ExitOptions":"MainMenu";
if(world.LostReply)throw new IOException("reply lost after original action");
return Task.CompletedTask;
}
}
private static (NativeSessionRecoveryChannel Bridge,Channel Pause,Channel Entry) Bridge(World world)
{
var pause=new Channel(world,false);var entry=new Channel(world,true);
return(new(pause,entry,new Recovery(world)){PollInterval=TimeSpan.FromMilliseconds(1),StepTimeout=TimeSpan.FromMilliseconds(80)},pause,entry);
}
private static async Task Fails<T>(Func<Task> run)where T:Exception
{try{await run();}catch(T){return;}throw new InvalidOperationException("Expected "+typeof(T).Name);}
[Test]public async Task ExistingMainMenuDoesNotPauseOrSelectAnotherExit()
{
var world=new World{State="MainMenu",ReadFailures=1};var c=Bridge(world);
await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
Check.That(c.Entry.Begun&&c.Entry.Ended&&!c.Pause.Begun);Check.Equal(0,world.Calls.Count);
}
[Test]public async Task ExistingPauseSubmenuResumesOnlyItsOriginalExitActions()
{
foreach(var state in new[]{"Pause","ExitOptions"})
{
var world=new World{State=state};var c=Bridge(world);
await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
Check.That(c.Entry.Begun&&c.Entry.Ended&&!c.Pause.Begun);Check.That(!world.Calls.Contains("@pause"));
Check.Equal("MainMenu",world.State);Check.Equal("Confirmed Quit To Main Menu",world.Calls.Last());
}
}
[Test]public async Task VerifiedExtensionLeavesToMenuAndEntryOnlyRestoresSolo()
{
var world=new World();var c=Bridge(world);var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){PollInterval=TimeSpan.FromMilliseconds(1),Timeout=TimeSpan.FromSeconds(1)};
await flow.RunAsync();Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&c.Entry.Ended);
}
[Test]public async Task OpenModeIsPreservedAfterTheActualMainMenu()
{
var world=new World{Mode="Open"};var c=Bridge(world);await new NativeRelogWorkflow(c.Bridge,()=>world.Player){PollInterval=TimeSpan.FromMilliseconds(1)}.RunAsync();
Check.Equal("SetupPlayWithEveryone",world.Calls.Last());Check.That(!world.Calls.Contains("SetupPlaySolo"));
}
[Test]public async Task LostRepliesUseObservedOutcomeWithoutRepeatingSelections()
{
var world=new World{LostReply=true,ReadFailures=2};var c=Bridge(world);await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu",string.Join(',',world.Calls));Check.That(c.Entry.Begun&&c.Entry.Ended&&c.Pause.Ended);
}
[Test]public async Task AnUnknownExitOutcomeNeverReplaysOrStartsEntry()
{
var world=new World{DoNotApply=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));await c.Bridge.EndAsync("owner");
Check.Equal("@pause,ExitOptions",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&!c.Entry.Begun);
}
[Test]public async Task DisabledAndAmbiguousExitOptionsFailBeforeAnyExit()
{
foreach(var world in new[]{new World{Disabled=true},new World{Duplicate=true}}){var c=Bridge(world);await Fails<InvalidDataException>(()=>c.Bridge.BeginAsync("owner",default));Check.Equal("@pause",string.Join(',',world.Calls));Check.That(!c.Entry.Begun&&c.Pause.Ended);}
}
[Test]public async Task StaleNativeFramesCannotOpenThePauseMenu()
{
var world=new World{Stale=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));Check.That(world.Calls.Count==0&&c.Pause.Ended&&!c.Entry.Begun);
}
[Test]public async Task CancellationCleansUpPauseWithoutSelectingAnExit()
{
using var stop=new CancellationTokenSource();var world=new World{CancelAtPause=stop};var c=Bridge(world);await Fails<OperationCanceledException>(()=>c.Bridge.BeginAsync("owner",stop.Token));await c.Bridge.EndAsync("owner");
Check.Equal("@pause",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&!c.Entry.Begun);
}
[Test]public async Task ADefinitiveFailedReceiptStopsWithoutEntering()
{
var world=new World{DoNotApply=true,Fail=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));Check.Equal("@pause,ExitOptions",string.Join(',',world.Calls));Check.That(!c.Entry.Begun);
}
[Test]public async Task ExitDeadlineIncludesTheWholeNativeBeginPreparation()
{
var world=new World{PrepareDelay=TimeSpan.FromSeconds(2)};var c=Bridge(world);
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromMilliseconds(30),Timeout=TimeSpan.FromSeconds(1),PollInterval=TimeSpan.FromMilliseconds(1)};
var watch=System.Diagnostics.Stopwatch.StartNew();await Fails<NativeMenuExitTimeoutException>(()=>flow.RunAsync());
Check.That(watch.Elapsed<TimeSpan.FromMilliseconds(500));Check.That(!flow.ReachedMainMenu&&!c.Pause.Begun&&!c.Entry.Begun);Check.Equal(0,world.Calls.Count);
}
[Test]public async Task AConfirmedMenuInsideBeginCancelsTheExitDeadlineBeforeSlowEntrySetup()
{
var world=new World{EntryBeginDelay=TimeSpan.FromMilliseconds(100)};var c=Bridge(world);var callbacks=0;
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromMilliseconds(25),Timeout=TimeSpan.FromSeconds(1),PollInterval=TimeSpan.FromMilliseconds(1),OnReachedMainMenu=()=>{callbacks++;return Task.CompletedTask;}};
await flow.RunAsync();Check.That(flow.ReachedMainMenu&&c.Bridge.ReachedMainMenu);Check.Equal(1,callbacks);
Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo",string.Join(',',world.Calls));
}
[Test]public async Task ExternalCancellationDuringBeginIsNotAnExitTimeoutOrRestartPermission()
{
using var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(20));var world=new World{PrepareDelay=TimeSpan.FromSeconds(2)};var c=Bridge(world);
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromSeconds(1),Timeout=TimeSpan.FromSeconds(2),PollInterval=TimeSpan.FromMilliseconds(1)};
await Fails<OperationCanceledException>(()=>flow.RunAsync(stop.Token));Check.That(!NativeMenuExitSafety.CanFallback(new OperationCanceledException(),stop.Token));Check.Equal(0,world.Calls.Count);
}
[Test]public async Task AProtocolFailureDuringBeginIsNotConvertedIntoAnExitTimeout()
{
var world=new World{Duplicate=true};var c=Bridge(world);
var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){MenuExitTimeout=TimeSpan.FromMilliseconds(50),Timeout=TimeSpan.FromSeconds(1),PollInterval=TimeSpan.FromMilliseconds(1)};
await Fails<InvalidDataException>(()=>flow.RunAsync());Check.That(!flow.ReachedMainMenu);Check.Equal("@pause",string.Join(',',world.Calls));
}
}