lightweight variant (#1)
Reviewed-on: #1 Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
using System.Collections.Concurrent;
|
||||
using CursorLang.Agent.Services;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Agent.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Making sense of Caps Lock presses: a short one differs from a long one only
|
||||
/// by when the key was released.
|
||||
/// </summary>
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A hold is never announced for a key that has already been let go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service together with its settings and the list of events that happened.
|
||||
/// </summary>
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private Harness(CapsLockHotkeyService service, AppSettings settings)
|
||||
{
|
||||
Service = service;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
internal CapsLockHotkeyService Service { get; }
|
||||
|
||||
internal AppSettings Settings { get; }
|
||||
|
||||
/// <summary>Events arrive from the interface thread and are read by the test thread.</summary>
|
||||
internal ConcurrentQueue<string> 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));
|
||||
|
||||
/// <summary>Delivers the hold countdown by hand, the way a late WM_TIMER does.</summary>
|
||||
internal void ForceHoldTick() => Pump.Run(Service.HandleHoldElapsed);
|
||||
|
||||
public void Dispose() => Pump.Run(Service.Dispose);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using CursorLang.Agent.Services;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Agent.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime of the tooltip. Its timer ticks on the message loop,
|
||||
/// so everything happens on the pump thread as well.
|
||||
/// </summary>
|
||||
public sealed class LayoutPopupServiceTests
|
||||
{
|
||||
private static readonly KeyboardLayout Russian = KeyboardLayout.FromLocaleId(0x0419);
|
||||
private static readonly KeyboardLayout English = KeyboardLayout.FromLocaleId(0x0409);
|
||||
|
||||
[Fact]
|
||||
public void Showing_puts_out_the_short_name_of_the_layout()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 10_000 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
});
|
||||
|
||||
Assert.Equal("RU", window.ShownText);
|
||||
Assert.Equal(1, window.ShowCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_goes_away_once_its_time_is_up()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
|
||||
});
|
||||
}
|
||||
|
||||
// The duration is read on every show: it is edited in the settings on the fly
|
||||
[Fact]
|
||||
public void A_new_duration_takes_effect_from_the_next_show()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 10_000 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
|
||||
settings.DurationMilliseconds = 30;
|
||||
service.Show(English);
|
||||
|
||||
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid by the new duration");
|
||||
});
|
||||
}
|
||||
|
||||
// Quick switching must not cut the tooltip off mid-word
|
||||
[Fact]
|
||||
public void Showing_again_extends_the_time_on_screen()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 60 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
service.Show(i % 2 == 0 ? Russian : English);
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(20));
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
}
|
||||
|
||||
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid after the last show");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_show_until_hidden_does_not_hide_by_itself()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 20 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.ShowUntilHidden(Russian);
|
||||
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Equal(1, window.ShowCalls);
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
|
||||
service.Hide();
|
||||
Assert.Equal(1, window.HideCalls);
|
||||
});
|
||||
}
|
||||
|
||||
// A show until hidden on top of an ordinary one also cancels the countdown
|
||||
[Fact]
|
||||
public void A_show_until_hidden_stops_a_running_countdown()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
service.ShowUntilHidden(English);
|
||||
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
Assert.Equal("EN", window.ShownText);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hiding_cancels_a_running_countdown()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 30 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
service.Hide();
|
||||
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// There must be no second hide from the timer
|
||||
Assert.Equal(1, window.HideCalls);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_service_closes_the_window()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings();
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
service.Dispose();
|
||||
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(50));
|
||||
});
|
||||
|
||||
Assert.Equal(1, window.CloseCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_the_timer_stays_silent()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 20 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
service.Dispose();
|
||||
|
||||
Pump.Pause(TimeSpan.FromMilliseconds(80));
|
||||
|
||||
Assert.Equal(0, window.HideCalls);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Showing_always_comes_before_hiding()
|
||||
{
|
||||
var window = new FakeLayoutPopupWindow();
|
||||
var settings = new AppSettings { DurationMilliseconds = 20 };
|
||||
|
||||
Pump.Run(() =>
|
||||
{
|
||||
using var service = new LayoutPopupService(window, settings);
|
||||
service.Show(Russian);
|
||||
|
||||
Pump.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
|
||||
|
||||
Assert.Equal(["show", "hide"], window.Calls);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user