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:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<PlatformTarget>AnyCPU</PlatformTarget>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>CursorLang.Agent.Tests</RootNamespace>
<AssemblyName>CursorLang.Agent.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1"/>
<PackageReference Include="coverlet.collector" Version="10.0.1"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj"/>
<ProjectReference Include="..\CursorLang.Agent\CursorLang.Agent.csproj"/>
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CursorLangExecutable</_Parameter1>
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang.Agent\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Project>
+289
View File
@@ -0,0 +1,289 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Tests;
/// <summary>
/// The application as a whole: the agent starting, the settings window it opens, the
/// single instance and the exit.
/// </summary>
/// <remarks>
/// None of this can be built inside the tests — the agent installs a system hook and
/// takes the place of the single instance for the whole session. It is therefore
/// started the way the user starts it: as a separate process.
///
/// If the application is already running in this session, the checks skip themselves:
/// meddling with someone else's running instance is not their business. They skip
/// themselves where there is no desktop to show a window on either — on a build agent
/// living as a Windows service, for one.
/// </remarks>
public sealed partial class EndToEndTests
{
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
/// <summary>How long "the application went on working" is worth watching for.</summary>
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
/// <summary>
/// The whole point of the background process, as a test rather than as a promise.
/// </summary>
/// <remarks>
/// The agent must not load the WPF rendering stack. It is checked on the running
/// process, not on its references: a reference costs nothing, a load costs the
/// hundred megabytes the split exists to avoid.
///
/// UI Automation and the assemblies behind it — WindowsBase and PresentationCore —
/// are deliberately not in the pattern: the caret fallback pulls them in on purpose
/// and only when it runs. What must never appear is the renderer itself.
/// </remarks>
[Fact]
public void The_agent_runs_without_the_rendering_stack()
{
using Launch launch = Launch.Start(StartupLaunch.Argument);
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
launch.Process.Refresh();
var loaded = launch.Process.Modules
.Cast<ProcessModule>()
.Select(module => module.ModuleName)
.Where(name => RenderingStack().IsMatch(name))
.ToList();
Assert.True(loaded.Count == 0, $"the agent loaded {string.Join(", ", loaded)}");
}
[GeneratedRegex("PresentationFramework|wpfgfx|milcore|PresentationNative", RegexOptions.IgnoreCase)]
private static partial Regex RenderingStack();
/// <summary>
/// Started by the user, the application shows the settings window — which lives in
/// a process of its own and is started by the agent.
/// </summary>
[Fact]
public void A_launch_by_the_user_opens_the_settings_window()
{
using Launch launch = Launch.Start();
Assert.NotEqual(IntPtr.Zero, launch.WaitForSettingsWindow());
Assert.False(launch.Process.HasExited);
}
/// <summary>
/// Started by Windows itself, the application goes straight to the tray: the
/// user asked for it to be there, not for a window to greet them.
/// </summary>
[Fact]
public void A_launch_by_Windows_shows_no_window()
{
using Launch launch = Launch.Start(StartupLaunch.Argument);
// Nothing is expected to appear, so the wait is for the whole time
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
Assert.Empty(Launch.SettingsProcesses());
}
// A second run raises no second agent but asks the running one for the window
[Fact]
public void The_second_run_ends_by_itself()
{
using Launch launch = Launch.Start(StartupLaunch.Argument);
Assert.False(launch.Process.WaitForExit(StayTimeout), "the agent ended by itself");
using Process second = Launch.StartProcess();
Assert.True(second.WaitForExit(ExitTimeout), "the second run did not end by itself");
Assert.Equal(0, second.ExitCode);
// And the first one keeps running
Assert.False(launch.Process.HasExited);
}
/// <summary>
/// Closing the settings window ends that process and leaves the agent alone.
/// </summary>
/// <remarks>
/// This is the behaviour the split was for. The window used to hide itself into the
/// tray, because closing it would have thrown away a visual tree the background half
/// was still using; now there is nothing shared to throw away, and the memory the
/// window took goes back to the system.
/// </remarks>
[Fact]
public void Closing_the_settings_window_leaves_the_agent_running()
{
using Launch launch = Launch.Start();
launch.WaitForSettingsWindow();
Process settings = Launch.SettingsProcesses().Single();
try
{
Assert.True(settings.CloseMainWindow(), "the window did not accept the request to close");
Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived its own closing");
}
finally
{
settings.Dispose();
}
Assert.False(launch.Process.HasExited);
}
/// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable
{
private const int UOI_NAME = 2;
private const string InteractiveWindowStation = "WinSta0";
[DllImport("user32.dll")]
private static extern IntPtr GetProcessWindowStation();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetUserObjectInformation(IntPtr hObj, int nIndex,
StringBuilder pvInfo, int nLength, out int lpnLengthNeeded);
private Launch(Process process) => Process = process;
internal Process Process { get; }
/// <summary>Starts the agent first — making sure the place is free.</summary>
internal static Launch Start(params string[] arguments)
{
if (!HasInteractiveDesktop())
{
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
}
if (Process.GetProcessesByName("CursorLang").Length > 0 || SettingsProcesses().Length > 0)
{
Assert.Skip("The application is already running — this check keeps out of someone else's run");
}
return new Launch(StartProcess(arguments));
}
/// <summary>Starts the agent the way the user — or Windows — does.</summary>
internal static Process StartProcess(params string[] arguments)
{
string path = ExecutablePath();
if (!File.Exists(path))
{
Assert.Skip($"The application is not built: {path}");
}
var start = new ProcessStartInfo(path) { UseShellExecute = true };
foreach (string argument in arguments)
{
start.ArgumentList.Add(argument);
}
return Process.Start(start)!;
}
/// <summary>The settings window processes running right now, if any.</summary>
internal static Process[] SettingsProcesses() => Process.GetProcessesByName("CursorLang.Settings");
/// <summary>
/// Waits for the settings window. It belongs to another process now, so the
/// wait is for that process to appear and put a window on the screen.
/// </summary>
internal IntPtr WaitForSettingsWindow()
{
DateTime deadline = DateTime.UtcNow + StartTimeout;
while (DateTime.UtcNow < deadline)
{
Process.Refresh();
if (Process.HasExited)
{
Assert.Fail($"The agent exited while starting with code {Process.ExitCode}");
}
foreach (Process settings in SettingsProcesses())
{
settings.Refresh();
IntPtr window = settings.MainWindowHandle;
settings.Dispose();
if (window != IntPtr.Zero)
{
return window;
}
}
Thread.Sleep(100);
}
Assert.Fail("The settings window never appeared");
return IntPtr.Zero;
}
/// <summary>
/// Whether there is a desktop here to show a window on. A service gets a
/// window station of its own — "Service-0x0-3e7$" and the like: a window can
/// be created there, yet nothing shows it. Only "WinSta0" is the interactive one.
/// </summary>
private static bool HasInteractiveDesktop()
{
IntPtr station = GetProcessWindowStation();
if (station == IntPtr.Zero)
{
return false;
}
var name = new StringBuilder(256);
return GetUserObjectInformation(station, UOI_NAME, name, name.Capacity * sizeof(char), out _)
&& name.ToString().Equals(InteractiveWindowStation, StringComparison.OrdinalIgnoreCase);
}
private static string ExecutablePath()
{
string configured = Assembly.GetExecutingAssembly()
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(attribute => attribute.Key == "CursorLangExecutable")
.Value!;
return Path.GetFullPath(configured);
}
public void Dispose()
{
foreach (Process settings in SettingsProcesses())
{
Kill(settings);
}
Kill(Process);
}
// The exit lives in a tray menu no test can reach, and the settings are saved
// as they change, so nothing is lost by ending the processes outright
private static void Kill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(ExitTimeout);
}
}
catch (InvalidOperationException)
{
// The process has already ended on its own
}
finally
{
process.Dispose();
}
}
}
}
@@ -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);
});
}
}
@@ -0,0 +1,57 @@
using CursorLang.Agent.Windows;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Agent.Tests.Windows;
/// <summary>
/// The one thing the settings window says to the agent.
/// </summary>
/// <remarks>
/// Both halves of it live apart — the message and the window class name in Core, the
/// window that answers in the agent — and nothing but a matching pair makes it work.
/// A renamed class or a renamed message would leave the agent showing yesterday's
/// settings until it is restarted, and nothing else would complain.
///
/// The agent's own window is used rather than a stand-in: what is being checked is
/// that the window the agent really creates is the one the message reaches.
/// </remarks>
public sealed class SettingsSignalTests
{
[Fact]
public void The_signal_reaches_the_agents_window()
{
var delivered = 0;
using AgentWindow window = Pump.Run(() =>
{
var created = new AgentWindow();
created.AddFilter((message, _, _) =>
{
if (message != SettingsSignal.Message)
{
return false;
}
delivered++;
return true;
});
return created;
});
Pump.Run(SettingsSignal.NotifyAgent);
Pump.Drain();
Assert.Equal(1, delivered);
}
// Nobody is listening, and that is a normal state of affairs: the settings window
// works perfectly well with no agent behind it
[Fact]
public void Signalling_with_no_agent_running_passes_without_consequence()
{
Pump.Run(SettingsSignal.NotifyAgent);
Pump.Drain();
}
}