using System.Collections.Concurrent; using CursorLang.Agent.Services; using CursorLang.Core.Models; using CursorLang.Tests.Shared; namespace CursorLang.Agent.Tests.Services; /// /// Making sense of Caps Lock presses: a short one differs from a long one only /// by when the key was released. /// public sealed class CapsLockHotkeyServiceTests { private const int CapsLock = 0x14; private const int LetterA = 0x41; [Fact] public void A_Caps_Lock_press_is_not_passed_on() { using var harness = Harness.Create(holdMilliseconds: 10_000); Assert.True(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true))); Assert.True(Pump.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: false))); } [Fact] public void The_other_keys_go_through_as_usual() { using var harness = Harness.Create(holdMilliseconds: 10_000); Assert.False(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true))); Assert.False(Pump.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: false))); Assert.Empty(harness.Events); } [Fact] public void A_short_press_yields_a_single_event() { using var harness = Harness.Create(holdMilliseconds: 10_000); harness.Press(); harness.Release(); Pump.WaitFor(() => harness.Events.Count == 1, "the short press event arrived"); Assert.Equal(["tap"], harness.Events); } [Fact] public void A_hold_is_announced_once_the_threshold_is_past() { using var harness = Harness.Create(holdMilliseconds: 20); harness.Press(); Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed"); Assert.Equal(["hold-start"], harness.Events); } [Fact] public void A_release_after_a_hold_yields_an_end_rather_than_a_press() { using var harness = Harness.Create(holdMilliseconds: 20); harness.Press(); Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed"); harness.Release(); Pump.WaitFor(() => harness.Events.Count == 2, "the end of the hold arrived"); Assert.Equal(["hold-start", "hold-end"], harness.Events); } // While the key is held down Windows repeats the press: the count runs from the first one [Fact] public void Auto_repeat_does_not_reset_the_countdown() { using var harness = Harness.Create(holdMilliseconds: 60); harness.Press(); for (int i = 0; i < 10; i++) { Pump.Pause(TimeSpan.FromMilliseconds(10)); harness.Press(); } Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold was announced despite the repeats"); } [Fact] public void A_quick_press_does_not_count_as_a_hold() { using var harness = Harness.Create(holdMilliseconds: 300); harness.Press(); harness.Release(); Pump.Pause(TimeSpan.FromMilliseconds(400)); Assert.Equal(["tap"], harness.Events); } // The interception may be removed with the key still down — by unticking // the setting, for one. The tooltip has to go away in that case [Fact] public void Removing_the_interception_during_a_hold_announces_its_end() { using var harness = Harness.Create(holdMilliseconds: 20); harness.Press(); Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed"); Pump.Run(harness.Service.Stop); Pump.WaitFor(() => harness.Events.Count == 2, "the end of the hold was announced"); Assert.Equal(["hold-start", "hold-end"], harness.Events); } [Fact] public void Removing_the_interception_without_a_hold_yields_no_events() { using var harness = Harness.Create(holdMilliseconds: 10_000); harness.Press(); Pump.Run(harness.Service.Stop); Pump.Pause(TimeSpan.FromMilliseconds(50)); Assert.Empty(harness.Events); } // After the interception is removed the hold countdown must not keep running [Fact] public void Removing_the_interception_stops_the_countdown() { using var harness = Harness.Create(holdMilliseconds: 40); harness.Press(); Pump.Run(harness.Service.Stop); Pump.Pause(TimeSpan.FromMilliseconds(120)); Assert.Empty(harness.Events); } [Fact] public void After_the_interception_is_removed_presses_count_from_scratch() { using var harness = Harness.Create(holdMilliseconds: 10_000); harness.Press(); Pump.Run(harness.Service.Stop); harness.Press(); harness.Release(); Pump.WaitFor(() => harness.Events.Count == 1, "a short press after the restart"); Assert.Equal(["tap"], harness.Events); } // When the application is shutting down nobody is waiting for events anymore [Fact] public void Closing_the_service_sends_out_no_events() { var harness = Harness.Create(holdMilliseconds: 20); harness.Press(); Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed"); Pump.Run(harness.Service.Dispose); Pump.Pause(TimeSpan.FromMilliseconds(80)); Assert.Equal(["hold-start"], harness.Events); } [Fact] public void The_hold_threshold_is_read_on_every_press() { using var harness = Harness.Create(holdMilliseconds: 10_000); harness.Press(); harness.Release(); Pump.WaitFor(() => harness.Events.Count == 1, "the first press counted as short"); harness.Settings.CapsLockHoldMilliseconds = 20; harness.Press(); Pump.WaitFor(() => harness.Events.Contains("hold-start"), "the new threshold took effect"); } [Fact] public void Before_the_start_there_is_no_interception() { using var harness = Harness.Create(holdMilliseconds: 10_000); Assert.False(harness.Service.IsRunning); } // The real system hook is installed and removed on the interface thread [Fact] public void The_interception_is_installed_and_removed() { using var harness = Harness.Create(holdMilliseconds: 10_000); Pump.Run(() => { harness.Service.Start(); Assert.True(harness.Service.IsRunning); // Starting again breaks nothing harness.Service.Start(); Assert.True(harness.Service.IsRunning); harness.Service.Stop(); Assert.False(harness.Service.IsRunning); // Nor does stopping again harness.Service.Stop(); Assert.False(harness.Service.IsRunning); }); } [Fact] public void Closing_removes_the_interception() { var harness = Harness.Create(holdMilliseconds: 10_000); Pump.Run(() => { harness.Service.Start(); harness.Service.Dispose(); Assert.False(harness.Service.IsRunning); }); } /// /// A hold is never announced for a key that has already been let go. /// /// /// The countdown started by the press can still be delivered just after the /// release: Windows does not withdraw a WM_TIMER it has already posted. Taken at /// face value it turned a tap into a hold — the popup came up showing the layout /// the tap was about to change away from, and the switch followed it. /// /// The tick is driven straight in here rather than waited for, because the point is /// the one ordering a real clock will not reproduce on demand. /// [Fact] public void A_hold_is_not_announced_after_the_key_has_been_released() { using var harness = Harness.Create(holdMilliseconds: 10_000); harness.Press(); harness.Release(); harness.ForceHoldTick(); Pump.Drain(); Assert.Equal(["tap"], harness.Events); } /// /// The service together with its settings and the list of events that happened. /// private sealed class Harness : IDisposable { private Harness(CapsLockHotkeyService service, AppSettings settings) { Service = service; Settings = settings; } internal CapsLockHotkeyService Service { get; } internal AppSettings Settings { get; } /// Events arrive from the interface thread and are read by the test thread. internal ConcurrentQueue Events { get; } = new(); internal static Harness Create(double holdMilliseconds) { var settings = new AppSettings { CapsLockHoldMilliseconds = holdMilliseconds }; // Events reach their subscribers the way they do in the agent: posted back // to the message loop, after the hook procedure has returned CapsLockHotkeyService service = Pump.Run(() => new CapsLockHotkeyService(settings, Pump.Post)); var harness = new Harness(service, settings); service.Tapped += (_, _) => harness.Events.Enqueue("tap"); service.HoldStarted += (_, _) => harness.Events.Enqueue("hold-start"); service.HoldEnded += (_, _) => harness.Events.Enqueue("hold-end"); return harness; } internal void Press() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true)); internal void Release() => Pump.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false)); /// Delivers the hold countdown by hand, the way a late WM_TIMER does. internal void ForceHoldTick() => Pump.Run(Service.HandleHoldElapsed); public void Dispose() => Pump.Run(Service.Dispose); } }