using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Session; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public class NativeRelogTests { private static readonly DateTimeOffset Initial = DateTimeOffset.Parse("2026-10-23T00:00:00Z"); private static PlayerState Player(string mode = "Solo") => new() { InGame = true, CommanderId = "F123", GameMode = mode, LoadGameAt = Initial }; private sealed class Menu : INativeRelogChannel { public PlayerState Player = NativeRelogTests.Player(); public string State = "InGame", Next = "InGame", LastId = ""; public long Sequence; public bool Applied, Begun, Ended, WithholdJournal, LoseAcknowledgement, CancelOnConfirm, Stale, RejectOnce, DropBeforeQueueOnce, OdysseyExitOptions; public int DisabledReads, Reads, ReadFailures; public CancellationTokenSource? Stop; public readonly List Calls = []; public Task BeginAsync(string owner, CancellationToken token) { Begun = true; return Task.CompletedTask; } public Task EndAsync(string owner) { Ended = true; return Task.CompletedTask; } public Task ReadAsync(string owner, CancellationToken token) { Reads++; if (ReadFailures-- > 0) throw new IOException("one interrupted local read"); var actions = State switch { "Pause" when OdysseyExitOptions => new[] { new NativeSessionMenuAction("InGame", true), new("ExitOptions", true) }, "Pause" => new[] { new NativeSessionMenuAction("QuitApplication", true), new("Quit To Main Menu", true) }, "ExitOptions" => new[] { new NativeSessionMenuAction("Confirmed Quit To Game", true), new("Confirmed Quit To Main Menu", DisabledReads-- <= 0), new("Confirmed Quit Game", true) }, "Confirm Quit To Main Menu" => new[] { new NativeSessionMenuAction("Confirmed Quit To Main Menu", DisabledReads-- <= 0) }, "MainMenu" => new[] { new NativeSessionMenuAction("ContinueMenu", true) }, "ContinueMenu" => new[] { new NativeSessionMenuAction("SetupPlaySolo", true), new("SetupPlayWithEveryone", true) }, _ => Array.Empty() }; if (CancelOnConfirm && State == "Confirm Quit To Main Menu") Stop!.Cancel(); return Task.FromResult(new NativeSessionFrame(++Sequence, DateTimeOffset.UtcNow.AddSeconds(Stale ? -10 : 0), State, Next, actions, LastId, Applied)); } public Task SubmitAsync(string owner, string id, NativeSessionFrame frame, string action, CancellationToken token) { token.ThrowIfCancellationRequested(); if (DropBeforeQueueOnce) { DropBeforeQueueOnce = false; throw new IOException("request never reached the native queue"); } if (RejectOnce) { RejectOnce = false; throw new NativeSessionStateChangedException("a concurrent original tick changed the menu"); } Check.That(frame.Stable); Check.That(action == "@pause" || frame.Actions.Any(a => a.Name == action && a.Enabled)); Calls.Add(action); LastId = id; Applied = true; State = Next = action switch { "@pause" => "Pause", "ExitOptions" => "ExitOptions", "Quit To Main Menu" => "Confirm Quit To Main Menu", "Confirmed Quit To Main Menu" => "MainMenu", "ContinueMenu" => "ContinueMenu", "SetupPlaySolo" or "SetupPlayWithEveryone" => "InGame", _ => throw new InvalidOperationException("Unexpected test menu selection") }; if (State == "MainMenu") Player = Player with { InGame = false }; if (action.StartsWith("SetupPlay", StringComparison.Ordinal) && !WithholdJournal) Player = Player with { InGame = true, LoadGameAt = Initial.AddMinutes(1) }; if (LoseAcknowledgement) throw new IOException("receipt lost after original selection"); return Task.CompletedTask; } } private static NativeRelogWorkflow Workflow(Menu menu) => new(menu, () => menu.Player) { PollInterval = TimeSpan.FromMilliseconds(1), Timeout = TimeSpan.FromSeconds(1) }; private static async Task Fails(Func work) where T : Exception { try { await work(); } catch (T) { return; } throw new InvalidOperationException("Expected " + typeof(T).Name); } [Test] public async Task SoloRelogTraversesOriginalMenusAndRequiresANewJournalSession() { var menu = new Menu(); await Workflow(menu).RunAsync(); Check.Equal("@pause,Quit To Main Menu,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo", string.Join(',', menu.Calls)); Check.That(menu.Ended && menu.Player.LoadGameAt > Initial); } [Test] public async Task OdysseyPauseUsesTheEnabledExitOptionsThenOriginalMainMenuConfirmation() { var menu = new Menu { OdysseyExitOptions = true, DisabledReads = 5, LoseAcknowledgement = true }; await Workflow(menu).RunAsync(); Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo", string.Join(',', menu.Calls)); Check.That(menu.Ended && menu.Player.LoadGameAt > Initial); } [Test] public void ExitOptionsCannotSelectResumeDesktopOrADisabledMainMenuConfirmation() { var frame = new NativeSessionFrame(1,DateTimeOffset.UtcNow,"ExitOptions","ExitOptions", [new("Confirmed Quit To Game",true),new("Confirmed Quit To Main Menu",false),new("Confirmed Quit Game",true)]); Check.That(NativeRelogWorkflow.ChooseAction(frame,"Solo",false) is null); frame=frame with{Actions=[new("Confirmed Quit To Main Menu",true)]}; Check.Equal("Confirmed Quit To Main Menu",NativeRelogWorkflow.ChooseAction(frame,"Solo",false)); Check.That(NativeRelogWorkflow.ChooseAction(frame,"Solo",true) is null); frame=frame with{NextState="MainMenu"}; Check.That(NativeRelogWorkflow.ChooseAction(frame,"Solo",false) is null); } [Test] public async Task OpenRelogPreservesTheOpenMode() { var menu = new Menu { Player = Player("Open") }; await Workflow(menu).RunAsync(); Check.Equal("SetupPlayWithEveryone", menu.Calls.Last()); Check.That(!menu.Calls.Contains("SetupPlaySolo") && menu.Ended); } [Test] public async Task NativeExitCountdownMustBecomeEligibleBeforeConfirmation() { var menu = new Menu { DisabledReads = 8 }; await Workflow(menu).RunAsync(); Check.That(menu.Reads >= 13 && menu.Calls.Count == 5 && menu.Ended); } [Test] public async Task CancellingAtTheExitCountdownNeverConfirmsOrEntersAnotherSession() { using var stop = new CancellationTokenSource(); var menu = new Menu { Stop = stop, CancelOnConfirm = true }; await Fails(() => Workflow(menu).RunAsync(stop.Token)); Check.Equal("@pause,Quit To Main Menu", string.Join(',', menu.Calls)); Check.That(menu.Ended); } [Test] public async Task LostSelectionRepliesUseTheOriginalReceiptWithoutSelectingTwice() { var menu = new Menu { LoseAcknowledgement = true }; await Workflow(menu).RunAsync(); Check.Equal(5, menu.Calls.Count); Check.That(menu.Calls.Distinct().Count() == 5 && menu.Ended); } [Test] public async Task TransientReadAndMenuRacesAreReadAgainBeforeRetrying() { var menu = new Menu { ReadFailures = 2, RejectOnce = true }; await Workflow(menu).RunAsync(); Check.Equal(5, menu.Calls.Count); Check.That(menu.Ended); } [Test] public async Task ARequestLostBeforeQueueingCanRetryTheSameIdWithoutDuplicatingSelections() { var menu = new Menu { DropBeforeQueueOnce = true }; var workflow = new NativeRelogWorkflow(menu, () => menu.Player) { PollInterval = TimeSpan.FromMilliseconds(1), ReceiptRetryAfter = TimeSpan.FromMilliseconds(5), Timeout = TimeSpan.FromSeconds(1) }; await workflow.RunAsync(); Check.Equal(5, menu.Calls.Count); Check.That(menu.Ended); } [Test] public async Task AReturnedInGameStateWithoutANewLoadGameEventCannotCompleteRelog() { var menu = new Menu { WithholdJournal = true }; var workflow = new NativeRelogWorkflow(menu, () => menu.Player) { PollInterval = TimeSpan.FromMilliseconds(1), Timeout = TimeSpan.FromMilliseconds(350) }; await Fails(() => workflow.RunAsync()); Check.Equal(5, menu.Calls.Count); Check.That(menu.Ended); } [Test] public async Task StaleMenuObservationsNeverCauseASelection() { var menu = new Menu { Stale = true }; var workflow = new NativeRelogWorkflow(menu, () => menu.Player) { PollInterval = TimeSpan.FromMilliseconds(1), Timeout = TimeSpan.FromMilliseconds(25) }; await Fails(() => workflow.RunAsync()); Check.Equal(0, menu.Calls.Count); Check.That(menu.Ended); } [Test] public async Task DepartureDeadlineAppliesBeforeTheMainMenuAndNeverReplaysAnExit() { var menu=new Menu{DisabledReads=int.MaxValue}; var workflow=new NativeRelogWorkflow(menu,()=>menu.Player){PollInterval=TimeSpan.FromMilliseconds(1),Timeout=TimeSpan.FromSeconds(2),MenuExitTimeout=TimeSpan.FromMilliseconds(40)}; await Fails(()=>workflow.RunAsync()); Check.That(!workflow.ReachedMainMenu&&menu.Ended);Check.That(!menu.Calls.Any(call=>call.StartsWith("SetupPlay",StringComparison.Ordinal))); } [Test] public async Task SlowReentryAfterTheVerifiedMainMenuDoesNotSpendTheStationExitDeadline() { var menu=new Menu{WithholdJournal=true}; var workflow=new NativeRelogWorkflow(menu,()=>menu.Player){PollInterval=TimeSpan.FromMilliseconds(1),Timeout=TimeSpan.FromSeconds(2),MenuExitTimeout=TimeSpan.FromMilliseconds(150)}; var resumed=Task.Run(async()=>{await Task.Delay(400);menu.Player=menu.Player with{InGame=true,LoadGameAt=Initial.AddMinutes(1)};}); await workflow.RunAsync();await resumed; Check.That(workflow.ReachedMainMenu&&menu.Ended);Check.Equal(5,menu.Calls.Count); } [Test] public async Task UnsupportedPrivateGroupsAreRejectedBeforeAnyOriginalMenuIsOpened() { var menu = new Menu { Player = Player("Group") }; await Fails(() => Workflow(menu).RunAsync()); Check.That(!menu.Begun && menu.Calls.Count == 0); } [Test] public void ExitSelectionExcludesDesktopQuitCancelAndUnstableStates() { foreach (var name in new[] { "QuitApplication", "QuitGame", "ExitToDesktop", "Cancel Quit To Main Menu" }) Check.That(!NativeRelogWorkflow.IsReturnToMenu(name)); var frame = new NativeSessionFrame(1, DateTimeOffset.UtcNow, "Pause", "Confirm Quit To Main Menu", [new("Quit To Main Menu", true)]); Check.That(NativeRelogWorkflow.ChooseAction(frame, "Solo", false) is null); frame = frame with { NextState = "Pause", Actions = [new("Quit To Main Menu", false)] }; Check.That(NativeRelogWorkflow.ChooseAction(frame, "Solo", false) is null); frame = frame with { Actions = [new("Quit To Main Menu", true), new("ExitToMenu", true)] }; Check.Throws(() => NativeRelogWorkflow.ChooseAction(frame, "Solo", false)); } }