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<string> 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<NativeSessionFrame> 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<NativeSessionMenuAction>()
};
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<T>(Func<Task> 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<OperationCanceledException>(() => 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<TimeoutException>(() => 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<TimeoutException>(() => 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<NativeMenuExitTimeoutException>(()=>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<NotSupportedException>(() => 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<InvalidOperationException>(() => NativeRelogWorkflow.ChooseAction(frame, "Solo", false));
}
}
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<string> 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<NativeSessionFrame> 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<NativeSessionMenuAction>()
};
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<T>(Func<Task> 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<OperationCanceledException>(() => 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<TimeoutException>(() => 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<TimeoutException>(() => 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<NativeMenuExitTimeoutException>(()=>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<NotSupportedException>(() => 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<InvalidOperationException>(() => NativeRelogWorkflow.ChooseAction(frame, "Solo", false));
}
}