XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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));
    }
}