using XFEExtension.NetCore.XUnit.Attributes;
using XFEEDToolBox.Core.Session;
namespace XFEEDToolBox.Test;
[TestFixture]
public class SessionTests
{
[Test] public void DesktopRestartExplicitlyDisablesVrAndUsesFreshOfficialAuthentication()
{
var steam=OfficialGameLaunchPlan.Desktop(true,true);
Check.Equal("/Steam,/autorun,/autoquit,/novr,/select,odyssey",string.Join(',',steam.Arguments));
Check.Equal("359320",steam.Environment["SteamAppId"]);
Check.Equal("359320",steam.Environment["SteamGameId"]);
var standalone=OfficialGameLaunchPlan.Desktop(false,false);
Check.Equal("/autorun,/autoquit,/novr,/select,horizons",string.Join(',',standalone.Arguments));
Check.Equal(0,standalone.Environment.Count);
}
private sealed class Fake : IGameSessionDriver
{
public bool CanRelog => RelogAvailable;
public bool RelogAvailable;
public GameSessionTarget Target = new(1, 100, "C:\\game\\EliteDangerous64.exe", "C:\\game\\EDLaunch.exe", true);
public readonly List<string> Calls = [];
public bool Exited, NoExit, WrongReplacement, MissingLauncher, GameAlreadyRunning;
public CancellationTokenSource? CancelOnKill;
public TaskCompletionSource? HoldValidate;
public Task<GameSessionTarget> FindTargetAsync(CancellationToken t) { Calls.Add("find"); return Task.FromResult(Target); }
public Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken t) { Calls.Add("find-launch"); return Task.FromResult(GameAlreadyRunning ? Target : Target with { ProcessId=0,StartedUtcTicks=0 }); }
public async Task ValidateLaunchAsync(GameSessionTarget p,CancellationToken t) { Calls.Add("validate-launch"); if(MissingLauncher)throw new InvalidOperationException("missing"); if(HoldValidate!=null)await HoldValidate.Task.WaitAsync(t); }
public async Task ValidateRestartAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("validate"); if (MissingLauncher) throw new InvalidOperationException("missing"); if (HoldValidate != null) await HoldValidate.Task.WaitAsync(t); }
public Task ValidateExitAsync(GameSessionTarget p,CancellationToken t){Calls.Add("validate-exit");if(p!=Target)throw new InvalidOperationException("identity changed");return Task.CompletedTask;}
public Task TerminateAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("kill"); Exited = !NoExit; CancelOnKill?.Cancel(); return Task.CompletedTask; }
public Task<bool> HasExitedAsync(GameSessionTarget p, CancellationToken t) => Task.FromResult(Exited);
public Task LaunchAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("launch"); Check.That(p.ProcessId==0||Exited); return Task.CompletedTask; }
public Task<GameSessionTarget?> FindReplacementAsync(GameSessionTarget p, CancellationToken t) => Task.FromResult<GameSessionTarget?>(p with { ProcessId = 2, StartedUtcTicks = 200, Executable = WrongReplacement ? "C:\\other\\EliteDangerous64.exe" : p.Executable });
public Task RelogAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("relog"); return Task.CompletedTask; }
}
private static GameSessionController Controller(Fake d, Func<bool>? route = null) => new(d, route ?? (() => false)) { PollInterval = TimeSpan.FromMilliseconds(1), ExitTimeout = TimeSpan.FromMilliseconds(60), StartTimeout = TimeSpan.FromMilliseconds(80) };
private static async Task Fails<T>(Func<Task> action) where T : Exception { try { await action(); } catch (T) { return; } throw new InvalidOperationException("Expected " + typeof(T).Name); }
[Test] public async Task RestartValidatesThenTerminatesBeforeRelaunch()
{
var d = new Fake(); await Controller(d).RunAsync(SessionOperation.Restart);
Check.Equal("find,validate,kill,launch", string.Join(',', d.Calls));
}
[Test] public async Task ShieldRestartRefusesReplacementProcessBeforeAnyTermination()
{
var d=new Fake();await Fails<InvalidOperationException>(()=>Controller(d).RunForTargetAsync(SessionOperation.Restart,d.Target with{StartedUtcTicks=99}));
Check.Equal("find",string.Join(',',d.Calls));
}
[Test] public async Task EmergencyExitsBeforeLauncherChecksAndDoesNotWaitForActiveRoute()
{
var d=new Fake();await Controller(d,()=>true).EmergencyRestartAsync(d.Target);
Check.Equal("validate-exit,kill,validate-launch,launch",string.Join(',',d.Calls));
d=new Fake{MissingLauncher=true};await Fails<InvalidOperationException>(()=>Controller(d,()=>true).EmergencyRestartAsync(d.Target));
Check.Equal("validate-exit,kill,validate-launch",string.Join(',',d.Calls));Check.That(d.Exited);
}
[Test] public async Task EmergencyStillRejectsDifferentProcessIdentityAndCancellation()
{
var d=new Fake();await Fails<InvalidOperationException>(()=>Controller(d).EmergencyRestartAsync(d.Target with{StartedUtcTicks=99}));Check.That(!d.Calls.Contains("kill"));
using var stop=new CancellationTokenSource();d=new Fake{CancelOnKill=stop};
await Fails<OperationCanceledException>(()=>Controller(d).EmergencyRestartAsync(d.Target,stop.Token));Check.That(!d.Calls.Contains("launch"));
}
[Test] public async Task UnsafeHandoffExitChecksTheExactProcessAndNeverRelaunches()
{
var driver=new Fake{MissingLauncher=true};
await Controller(driver,()=>true).EmergencyExitAsync(driver.Target);
Check.Equal("validate-exit,kill",string.Join(',',driver.Calls));Check.That(driver.Exited);
driver=new Fake();
await Fails<InvalidOperationException>(()=>Controller(driver).EmergencyExitAsync(driver.Target with{StartedUtcTicks=99}));
Check.Equal("validate-exit",string.Join(',',driver.Calls));
}
[Test] public async Task MissingLauncherLeavesGameRunning()
{
var d = new Fake { MissingLauncher = true }; await Fails<InvalidOperationException>(() => Controller(d).RunAsync(SessionOperation.Restart)); Check.That(!d.Calls.Contains("kill"));
}
[Test] public async Task ExitTimeoutCannotLaunchAnotherInstance()
{
var d = new Fake { NoExit = true }; await Fails<TimeoutException>(() => Controller(d).RunAsync(SessionOperation.Restart)); Check.That(!d.Calls.Contains("launch"));
}
[Test] public async Task CancellationAfterTerminationDoesNotLaunch()
{
using var stop = new CancellationTokenSource(); var d = new Fake { CancelOnKill = stop }; var c = Controller(d);
await Fails<OperationCanceledException>(() => c.RunAsync(SessionOperation.Restart, stop.Token)); Check.That(!d.Calls.Contains("launch") && !c.Active);
}
[Test] public async Task OtherInstallationCannotSatisfyCompletion()
{
var d = new Fake { WrongReplacement = true }; await Fails<TimeoutException>(() => Controller(d).RunAsync(SessionOperation.Restart));
}
[Test] public async Task RepeatedRequestCannotStartSecondWorkflow()
{
using var stop = new CancellationTokenSource(); var d = new Fake { HoldValidate = new(TaskCreationOptions.RunContinuationsAsynchronously) }; var c = Controller(d);
var first = c.RunAsync(SessionOperation.Restart, stop.Token); await Fails<InvalidOperationException>(() => c.RunAsync(SessionOperation.Restart));
stop.Cancel(); await Fails<OperationCanceledException>(() => first); Check.Equal(1, d.Calls.Count(x => x == "find"));
}
[Test] public async Task UnverifiedNativeRelogAndActiveRouteInvokeNothing()
{
var d = new Fake(); await Fails<NotSupportedException>(() => Controller(d).RunAsync(SessionOperation.Relog));
await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Restart));
await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Start)); Check.Equal(0, d.Calls.Count);
}
[Test] public async Task AvailableNativeRelogUsesItsOwnWorkflowWithoutTerminatingOrLaunchingProcesses()
{
var driver=new Fake{RelogAvailable=true};var controller=Controller(driver);
await controller.RunAsync(SessionOperation.Relog);
Check.Equal("find,relog",string.Join(',',driver.Calls));Check.That(!controller.Active&&!driver.Exited);
}
[Test] public async Task StartLaunchesStoppedGameWithoutTermination()
{
var d=new Fake();var controller=Controller(d);var messages=new List<string>();controller.Progress+=p=>messages.Add(p.Message);
await controller.RunAsync(SessionOperation.Start);
Check.Equal("find-launch,validate-launch,launch",string.Join(',',d.Calls));
Check.That(!controller.Active&&messages.Any(m=>m.Contains("游戏已启动",StringComparison.Ordinal)));
}
[Test] public async Task StartRecognizesExistingGameWithoutReopeningLauncher()
{
var d=new Fake{GameAlreadyRunning=true};var controller=Controller(d);var messages=new List<string>();controller.Progress+=p=>messages.Add(p.Message);
await controller.RunAsync(SessionOperation.Start);
Check.Equal("find-launch",string.Join(',',d.Calls));Check.That(messages.Any(m=>m.Contains("无需重复启动",StringComparison.Ordinal))&&!controller.Active);
}
[Test] public async Task StartWithoutLauncherDoesNotKillOrLaunchAnything()
{
var d=new Fake{MissingLauncher=true};var controller=Controller(d);
await Fails<InvalidOperationException>(()=>controller.RunAsync(SessionOperation.Start));
Check.Equal("find-launch,validate-launch",string.Join(',',d.Calls));Check.That(!controller.Active);
}
[Test] public async Task StartCannotAcceptAnotherInstallationAsSuccessful()
{
var d=new Fake{WrongReplacement=true};var controller=Controller(d);
await Fails<TimeoutException>(()=>controller.RunAsync(SessionOperation.Start));Check.That(!d.Calls.Contains("kill")&&!controller.Active);
}
[Test] public async Task CancelledStartNeverSubmitsLaunchAfterValidation()
{
using var stop=new CancellationTokenSource();var d=new Fake{HoldValidate=new(TaskCreationOptions.RunContinuationsAsynchronously)};var controller=Controller(d);
var operation=controller.RunAsync(SessionOperation.Start,stop.Token);stop.Cancel();
await Fails<OperationCanceledException>(()=>operation);Check.Equal("find-launch,validate-launch",string.Join(',',d.Calls));Check.That(!controller.Active);
}
[Test] public async Task RepeatedStartRequestSharesTheSessionOperationGate()
{
using var stop=new CancellationTokenSource();var d=new Fake{HoldValidate=new(TaskCreationOptions.RunContinuationsAsynchronously)};var controller=Controller(d);
var first=controller.RunAsync(SessionOperation.Start,stop.Token);
await Fails<InvalidOperationException>(()=>controller.RunAsync(SessionOperation.Start));stop.Cancel();
await Fails<OperationCanceledException>(()=>first);Check.Equal(1,d.Calls.Count(c=>c=="find-launch"));Check.That(!d.Calls.Contains("kill"));
}
[Test] public async Task StartCanBeRetriedAfterAnUnconfirmedLaunch()
{
var d=new Fake{WrongReplacement=true};var controller=Controller(d);
await Fails<TimeoutException>(()=>controller.RunAsync(SessionOperation.Start));d.WrongReplacement=false;
await controller.RunAsync(SessionOperation.Start);Check.Equal(2,d.Calls.Count(c=>c=="launch"));Check.That(!d.Calls.Contains("kill")&&!controller.Active);
}
}
[TestFixture]
public class HotkeyTests
{
private sealed class Registry : IHotkeyRegistry
{
public readonly Dictionary<int, HotkeyGesture> Keys = [];
public HotkeyGesture? Conflict;
public void Register(int id, HotkeyGesture gesture) { if (gesture == Conflict || Keys.ContainsValue(gesture)) throw new InvalidOperationException("conflict"); Keys.Add(id, gesture); }
public void Unregister(int id) => Keys.Remove(id);
}
[Test] public void NormalizesGesturesAndRejectsReservedOrIncompleteKeys()
{
Check.Equal("Ctrl+Alt+F9", HotkeyGesture.Parse(" alt + control + f9 ").ToString());
foreach (var bad in new[] { "F9", "Shift+F9", "Ctrl+F12", "Win+F9", "Ctrl+Ctrl+Q", "Ctrl+", "Ctrl+Escape", "Ctrl+F25" }) Check.Throws<ArgumentException>(() => HotkeyGesture.Parse(bad));
}
[Test] public void ConflictRetainsPreviousBindingsAndRollsBackAdditions()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
keys.Apply(new(true, "Ctrl+Alt+F9", "Ctrl+Alt+F10")); var original = registry.Keys.ToArray();
registry.Conflict = HotkeyGesture.Parse("Ctrl+Alt+F8");
Check.Throws<InvalidOperationException>(() => keys.Apply(new(true, "Ctrl+Alt+F7", "Ctrl+Alt+F8")));
Check.Equal(2, registry.Keys.Count); Check.That(original.All(x => registry.Keys.ContainsKey(x.Key))); Check.Equal("Ctrl+Alt+F10", keys.Settings.Restart);
}
[Test] public void SwappingActionsReusesRegistrationAndDisablingUnregisters()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
keys.Apply(new(true, "Ctrl+Alt+F9", "Ctrl+Alt+F10")); var id = registry.Keys.Single(x => x.Value == HotkeyGesture.Parse("Ctrl+Alt+F9")).Key;
keys.Apply(new(true, "Ctrl+Alt+F10", "Ctrl+Alt+F9")); Check.Equal(SessionOperation.Restart, keys.Resolve(id)!.Value);
keys.Apply(new(false)); Check.Equal(0, registry.Keys.Count); Check.That(keys.Resolve(id) is null);
}
[Test] public void DuplicateActionKeysAreRejectedBeforeMutation()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
Check.Throws<ArgumentException>(() => keys.Apply(new(true, "Ctrl+Q", "Control+q"))); Check.Equal(0, registry.Keys.Count);
}
[Test] public void TurningOffWorksDespiteInvalidDraftGesture()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
keys.Apply(new(true, Restart: "Ctrl+Alt+F10")); keys.Apply(new(false, Restart: "Ctrl+")); Check.Equal(0, registry.Keys.Count);
}
}
using XFEExtension.NetCore.XUnit.Attributes;
using XFEEDToolBox.Core.Session;
namespace XFEEDToolBox.Test;
[TestFixture]
public class SessionTests
{
[Test] public void DesktopRestartExplicitlyDisablesVrAndUsesFreshOfficialAuthentication()
{
var steam=OfficialGameLaunchPlan.Desktop(true,true);
Check.Equal("/Steam,/autorun,/autoquit,/novr,/select,odyssey",string.Join(',',steam.Arguments));
Check.Equal("359320",steam.Environment["SteamAppId"]);
Check.Equal("359320",steam.Environment["SteamGameId"]);
var standalone=OfficialGameLaunchPlan.Desktop(false,false);
Check.Equal("/autorun,/autoquit,/novr,/select,horizons",string.Join(',',standalone.Arguments));
Check.Equal(0,standalone.Environment.Count);
}
private sealed class Fake : IGameSessionDriver
{
public bool CanRelog => RelogAvailable;
public bool RelogAvailable;
public GameSessionTarget Target = new(1, 100, "C:\\game\\EliteDangerous64.exe", "C:\\game\\EDLaunch.exe", true);
public readonly List<string> Calls = [];
public bool Exited, NoExit, WrongReplacement, MissingLauncher, GameAlreadyRunning;
public CancellationTokenSource? CancelOnKill;
public TaskCompletionSource? HoldValidate;
public Task<GameSessionTarget> FindTargetAsync(CancellationToken t) { Calls.Add("find"); return Task.FromResult(Target); }
public Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken t) { Calls.Add("find-launch"); return Task.FromResult(GameAlreadyRunning ? Target : Target with { ProcessId=0,StartedUtcTicks=0 }); }
public async Task ValidateLaunchAsync(GameSessionTarget p,CancellationToken t) { Calls.Add("validate-launch"); if(MissingLauncher)throw new InvalidOperationException("missing"); if(HoldValidate!=null)await HoldValidate.Task.WaitAsync(t); }
public async Task ValidateRestartAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("validate"); if (MissingLauncher) throw new InvalidOperationException("missing"); if (HoldValidate != null) await HoldValidate.Task.WaitAsync(t); }
public Task ValidateExitAsync(GameSessionTarget p,CancellationToken t){Calls.Add("validate-exit");if(p!=Target)throw new InvalidOperationException("identity changed");return Task.CompletedTask;}
public Task TerminateAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("kill"); Exited = !NoExit; CancelOnKill?.Cancel(); return Task.CompletedTask; }
public Task<bool> HasExitedAsync(GameSessionTarget p, CancellationToken t) => Task.FromResult(Exited);
public Task LaunchAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("launch"); Check.That(p.ProcessId==0||Exited); return Task.CompletedTask; }
public Task<GameSessionTarget?> FindReplacementAsync(GameSessionTarget p, CancellationToken t) => Task.FromResult<GameSessionTarget?>(p with { ProcessId = 2, StartedUtcTicks = 200, Executable = WrongReplacement ? "C:\\other\\EliteDangerous64.exe" : p.Executable });
public Task RelogAsync(GameSessionTarget p, CancellationToken t) { Calls.Add("relog"); return Task.CompletedTask; }
}
private static GameSessionController Controller(Fake d, Func<bool>? route = null) => new(d, route ?? (() => false)) { PollInterval = TimeSpan.FromMilliseconds(1), ExitTimeout = TimeSpan.FromMilliseconds(60), StartTimeout = TimeSpan.FromMilliseconds(80) };
private static async Task Fails<T>(Func<Task> action) where T : Exception { try { await action(); } catch (T) { return; } throw new InvalidOperationException("Expected " + typeof(T).Name); }
[Test] public async Task RestartValidatesThenTerminatesBeforeRelaunch()
{
var d = new Fake(); await Controller(d).RunAsync(SessionOperation.Restart);
Check.Equal("find,validate,kill,launch", string.Join(',', d.Calls));
}
[Test] public async Task ShieldRestartRefusesReplacementProcessBeforeAnyTermination()
{
var d=new Fake();await Fails<InvalidOperationException>(()=>Controller(d).RunForTargetAsync(SessionOperation.Restart,d.Target with{StartedUtcTicks=99}));
Check.Equal("find",string.Join(',',d.Calls));
}
[Test] public async Task EmergencyExitsBeforeLauncherChecksAndDoesNotWaitForActiveRoute()
{
var d=new Fake();await Controller(d,()=>true).EmergencyRestartAsync(d.Target);
Check.Equal("validate-exit,kill,validate-launch,launch",string.Join(',',d.Calls));
d=new Fake{MissingLauncher=true};await Fails<InvalidOperationException>(()=>Controller(d,()=>true).EmergencyRestartAsync(d.Target));
Check.Equal("validate-exit,kill,validate-launch",string.Join(',',d.Calls));Check.That(d.Exited);
}
[Test] public async Task EmergencyStillRejectsDifferentProcessIdentityAndCancellation()
{
var d=new Fake();await Fails<InvalidOperationException>(()=>Controller(d).EmergencyRestartAsync(d.Target with{StartedUtcTicks=99}));Check.That(!d.Calls.Contains("kill"));
using var stop=new CancellationTokenSource();d=new Fake{CancelOnKill=stop};
await Fails<OperationCanceledException>(()=>Controller(d).EmergencyRestartAsync(d.Target,stop.Token));Check.That(!d.Calls.Contains("launch"));
}
[Test] public async Task UnsafeHandoffExitChecksTheExactProcessAndNeverRelaunches()
{
var driver=new Fake{MissingLauncher=true};
await Controller(driver,()=>true).EmergencyExitAsync(driver.Target);
Check.Equal("validate-exit,kill",string.Join(',',driver.Calls));Check.That(driver.Exited);
driver=new Fake();
await Fails<InvalidOperationException>(()=>Controller(driver).EmergencyExitAsync(driver.Target with{StartedUtcTicks=99}));
Check.Equal("validate-exit",string.Join(',',driver.Calls));
}
[Test] public async Task MissingLauncherLeavesGameRunning()
{
var d = new Fake { MissingLauncher = true }; await Fails<InvalidOperationException>(() => Controller(d).RunAsync(SessionOperation.Restart)); Check.That(!d.Calls.Contains("kill"));
}
[Test] public async Task ExitTimeoutCannotLaunchAnotherInstance()
{
var d = new Fake { NoExit = true }; await Fails<TimeoutException>(() => Controller(d).RunAsync(SessionOperation.Restart)); Check.That(!d.Calls.Contains("launch"));
}
[Test] public async Task CancellationAfterTerminationDoesNotLaunch()
{
using var stop = new CancellationTokenSource(); var d = new Fake { CancelOnKill = stop }; var c = Controller(d);
await Fails<OperationCanceledException>(() => c.RunAsync(SessionOperation.Restart, stop.Token)); Check.That(!d.Calls.Contains("launch") && !c.Active);
}
[Test] public async Task OtherInstallationCannotSatisfyCompletion()
{
var d = new Fake { WrongReplacement = true }; await Fails<TimeoutException>(() => Controller(d).RunAsync(SessionOperation.Restart));
}
[Test] public async Task RepeatedRequestCannotStartSecondWorkflow()
{
using var stop = new CancellationTokenSource(); var d = new Fake { HoldValidate = new(TaskCreationOptions.RunContinuationsAsynchronously) }; var c = Controller(d);
var first = c.RunAsync(SessionOperation.Restart, stop.Token); await Fails<InvalidOperationException>(() => c.RunAsync(SessionOperation.Restart));
stop.Cancel(); await Fails<OperationCanceledException>(() => first); Check.Equal(1, d.Calls.Count(x => x == "find"));
}
[Test] public async Task UnverifiedNativeRelogAndActiveRouteInvokeNothing()
{
var d = new Fake(); await Fails<NotSupportedException>(() => Controller(d).RunAsync(SessionOperation.Relog));
await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Restart));
await Fails<InvalidOperationException>(() => Controller(d, () => true).RunAsync(SessionOperation.Start)); Check.Equal(0, d.Calls.Count);
}
[Test] public async Task AvailableNativeRelogUsesItsOwnWorkflowWithoutTerminatingOrLaunchingProcesses()
{
var driver=new Fake{RelogAvailable=true};var controller=Controller(driver);
await controller.RunAsync(SessionOperation.Relog);
Check.Equal("find,relog",string.Join(',',driver.Calls));Check.That(!controller.Active&&!driver.Exited);
}
[Test] public async Task StartLaunchesStoppedGameWithoutTermination()
{
var d=new Fake();var controller=Controller(d);var messages=new List<string>();controller.Progress+=p=>messages.Add(p.Message);
await controller.RunAsync(SessionOperation.Start);
Check.Equal("find-launch,validate-launch,launch",string.Join(',',d.Calls));
Check.That(!controller.Active&&messages.Any(m=>m.Contains("游戏已启动",StringComparison.Ordinal)));
}
[Test] public async Task StartRecognizesExistingGameWithoutReopeningLauncher()
{
var d=new Fake{GameAlreadyRunning=true};var controller=Controller(d);var messages=new List<string>();controller.Progress+=p=>messages.Add(p.Message);
await controller.RunAsync(SessionOperation.Start);
Check.Equal("find-launch",string.Join(',',d.Calls));Check.That(messages.Any(m=>m.Contains("无需重复启动",StringComparison.Ordinal))&&!controller.Active);
}
[Test] public async Task StartWithoutLauncherDoesNotKillOrLaunchAnything()
{
var d=new Fake{MissingLauncher=true};var controller=Controller(d);
await Fails<InvalidOperationException>(()=>controller.RunAsync(SessionOperation.Start));
Check.Equal("find-launch,validate-launch",string.Join(',',d.Calls));Check.That(!controller.Active);
}
[Test] public async Task StartCannotAcceptAnotherInstallationAsSuccessful()
{
var d=new Fake{WrongReplacement=true};var controller=Controller(d);
await Fails<TimeoutException>(()=>controller.RunAsync(SessionOperation.Start));Check.That(!d.Calls.Contains("kill")&&!controller.Active);
}
[Test] public async Task CancelledStartNeverSubmitsLaunchAfterValidation()
{
using var stop=new CancellationTokenSource();var d=new Fake{HoldValidate=new(TaskCreationOptions.RunContinuationsAsynchronously)};var controller=Controller(d);
var operation=controller.RunAsync(SessionOperation.Start,stop.Token);stop.Cancel();
await Fails<OperationCanceledException>(()=>operation);Check.Equal("find-launch,validate-launch",string.Join(',',d.Calls));Check.That(!controller.Active);
}
[Test] public async Task RepeatedStartRequestSharesTheSessionOperationGate()
{
using var stop=new CancellationTokenSource();var d=new Fake{HoldValidate=new(TaskCreationOptions.RunContinuationsAsynchronously)};var controller=Controller(d);
var first=controller.RunAsync(SessionOperation.Start,stop.Token);
await Fails<InvalidOperationException>(()=>controller.RunAsync(SessionOperation.Start));stop.Cancel();
await Fails<OperationCanceledException>(()=>first);Check.Equal(1,d.Calls.Count(c=>c=="find-launch"));Check.That(!d.Calls.Contains("kill"));
}
[Test] public async Task StartCanBeRetriedAfterAnUnconfirmedLaunch()
{
var d=new Fake{WrongReplacement=true};var controller=Controller(d);
await Fails<TimeoutException>(()=>controller.RunAsync(SessionOperation.Start));d.WrongReplacement=false;
await controller.RunAsync(SessionOperation.Start);Check.Equal(2,d.Calls.Count(c=>c=="launch"));Check.That(!d.Calls.Contains("kill")&&!controller.Active);
}
}
[TestFixture]
public class HotkeyTests
{
private sealed class Registry : IHotkeyRegistry
{
public readonly Dictionary<int, HotkeyGesture> Keys = [];
public HotkeyGesture? Conflict;
public void Register(int id, HotkeyGesture gesture) { if (gesture == Conflict || Keys.ContainsValue(gesture)) throw new InvalidOperationException("conflict"); Keys.Add(id, gesture); }
public void Unregister(int id) => Keys.Remove(id);
}
[Test] public void NormalizesGesturesAndRejectsReservedOrIncompleteKeys()
{
Check.Equal("Ctrl+Alt+F9", HotkeyGesture.Parse(" alt + control + f9 ").ToString());
foreach (var bad in new[] { "F9", "Shift+F9", "Ctrl+F12", "Win+F9", "Ctrl+Ctrl+Q", "Ctrl+", "Ctrl+Escape", "Ctrl+F25" }) Check.Throws<ArgumentException>(() => HotkeyGesture.Parse(bad));
}
[Test] public void ConflictRetainsPreviousBindingsAndRollsBackAdditions()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
keys.Apply(new(true, "Ctrl+Alt+F9", "Ctrl+Alt+F10")); var original = registry.Keys.ToArray();
registry.Conflict = HotkeyGesture.Parse("Ctrl+Alt+F8");
Check.Throws<InvalidOperationException>(() => keys.Apply(new(true, "Ctrl+Alt+F7", "Ctrl+Alt+F8")));
Check.Equal(2, registry.Keys.Count); Check.That(original.All(x => registry.Keys.ContainsKey(x.Key))); Check.Equal("Ctrl+Alt+F10", keys.Settings.Restart);
}
[Test] public void SwappingActionsReusesRegistrationAndDisablingUnregisters()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
keys.Apply(new(true, "Ctrl+Alt+F9", "Ctrl+Alt+F10")); var id = registry.Keys.Single(x => x.Value == HotkeyGesture.Parse("Ctrl+Alt+F9")).Key;
keys.Apply(new(true, "Ctrl+Alt+F10", "Ctrl+Alt+F9")); Check.Equal(SessionOperation.Restart, keys.Resolve(id)!.Value);
keys.Apply(new(false)); Check.Equal(0, registry.Keys.Count); Check.That(keys.Resolve(id) is null);
}
[Test] public void DuplicateActionKeysAreRejectedBeforeMutation()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
Check.Throws<ArgumentException>(() => keys.Apply(new(true, "Ctrl+Q", "Control+q"))); Check.Equal(0, registry.Keys.Count);
}
[Test] public void TurningOffWorksDespiteInvalidDraftGesture()
{
var registry = new Registry(); using var keys = new GlobalHotkeyBindings(registry);
keys.Apply(new(true, Restart: "Ctrl+Alt+F10")); keys.Apply(new(false, Restart: "Ctrl+")); Check.Equal(0, registry.Keys.Count);
}
}