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 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 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 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{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(Func 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(()=>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(()=>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(()=>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(()=>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(()=>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(()=>flow.RunAsync()); Check.That(watch.Elapsedworld.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(()=>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(()=>flow.RunAsync());Check.That(!flow.ReachedMainMenu);Check.Equal("@pause",string.Join(',',world.Calls)); } }