diff --git a/CursorLang.Tests/AppTests.cs b/CursorLang.Tests/AppTests.cs
new file mode 100644
index 0000000..100bd20
--- /dev/null
+++ b/CursorLang.Tests/AppTests.cs
@@ -0,0 +1,119 @@
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.ViewModels;
+using CursorLang.Views;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CursorLang.Tests;
+
+///
+/// What the container is made of. The tests cannot build the application whole
+/// — it would raise windows and take the place of the single instance — but
+/// checking that everything needed is declared and resolvable works without that.
+///
+public sealed class AppTests
+{
+ [Theory]
+ [InlineData(typeof(SettingsService))]
+ [InlineData(typeof(ThemeService))]
+ [InlineData(typeof(IThemeService))]
+ [InlineData(typeof(MainWindowPlacement))]
+ [InlineData(typeof(ILocalizationService))]
+ [InlineData(typeof(IStartupService))]
+ [InlineData(typeof(IUpdateService))]
+ [InlineData(typeof(UpdateOptions))]
+ [InlineData(typeof(IKeyboardLayoutService))]
+ [InlineData(typeof(ILayoutPopupService))]
+ [InlineData(typeof(ICapsLockHotkeyService))]
+ [InlineData(typeof(ILayoutPopupWindow))]
+ [InlineData(typeof(LayoutNotificationCoordinator))]
+ [InlineData(typeof(CapsLockSwitchCoordinator))]
+ [InlineData(typeof(LayoutPopupViewModel))]
+ [InlineData(typeof(SettingsViewModel))]
+ [InlineData(typeof(UpdateViewModel))]
+ [InlineData(typeof(LayoutPopupWindow))]
+ [InlineData(typeof(MainWindow))]
+ [InlineData(typeof(KeyboardLayoutOptions))]
+ [InlineData(typeof(AppSettings))]
+ public void Everything_the_app_needs_is_declared_in_the_container(Type service)
+ {
+ Assert.Contains(Describe(), descriptor => descriptor.ServiceType == service);
+ }
+
+ // The settings, the tooltip and the layout watch have to be shared by the
+ // whole application: a second copy of them would mean a second
+ // tooltip or lost settings
+ [Fact]
+ public void Everything_in_the_container_is_declared_as_a_single_copy()
+ {
+ Assert.All(Describe(), descriptor =>
+ Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime));
+ }
+
+ [Fact]
+ public void Every_service_is_declared_once()
+ {
+ var types = Describe().Select(descriptor => descriptor.ServiceType).ToList();
+
+ Assert.Equal(types.Count, types.Distinct().Count());
+ }
+
+ ///
+ /// The dependencies of every service have to be resolvable. The check runs
+ /// while the container is built and creates no services itself.
+ ///
+ [Fact]
+ public void The_service_dependencies_come_together_with_nothing_missing()
+ {
+ var services = new ServiceCollection();
+ App.ConfigureServices(services);
+
+ ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
+ {
+ ValidateOnBuild = true,
+ ValidateScopes = true,
+ });
+
+ // Ensure the provider was built successfully — this serves as an assertion
+ // so the test framework recognizes this as a meaningful test.
+ Assert.NotNull(provider);
+
+ provider.Dispose();
+ }
+
+ [Fact]
+ public void The_settings_come_from_the_settings_service()
+ {
+ ServiceDescriptor settings = Describe()
+ .Single(descriptor => descriptor.ServiceType == typeof(AppSettings));
+
+ // The settings are not created anew but read from disk by the service
+ Assert.NotNull(settings.ImplementationFactory);
+ }
+
+ [Fact]
+ public void The_tooltip_window_and_its_interface_are_one_window()
+ {
+ ServiceDescriptor window = Describe()
+ .Single(descriptor => descriptor.ServiceType == typeof(ILayoutPopupWindow));
+
+ Assert.NotNull(window.ImplementationFactory);
+ }
+
+ [Fact]
+ public void The_theme_and_its_interface_are_one_service()
+ {
+ ServiceDescriptor theme = Describe()
+ .Single(descriptor => descriptor.ServiceType == typeof(IThemeService));
+
+ Assert.NotNull(theme.ImplementationFactory);
+ }
+
+ private static ServiceCollection Describe()
+ {
+ var services = new ServiceCollection();
+ App.ConfigureServices(services);
+
+ return services;
+ }
+}
diff --git a/CursorLang.Tests/CursorLang.Tests.csproj b/CursorLang.Tests/CursorLang.Tests.csproj
new file mode 100644
index 0000000..baad7b2
--- /dev/null
+++ b/CursorLang.Tests/CursorLang.Tests.csproj
@@ -0,0 +1,61 @@
+
+
+
+ net10.0-windows10.0.19041.0
+ 10.0.17763.0
+ enable
+ enable
+ true
+ Exe
+ AnyCPU
+ true
+ true
+ $(NoWarn);CS1591
+ false
+ true
+ $(NoWarn);IDE0130
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>CursorLangExecutable
+ <_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe
+
+
+
+
+
+
+
+
+ $(MSBuildThisFileDirectory)..\CursorLang\Views\MainWindow.xaml
+
+
+
+
+ $(IntermediateOutputPath)MainWindow.xaml.txt
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CursorLang.Tests/EndToEndTests.cs b/CursorLang.Tests/EndToEndTests.cs
new file mode 100644
index 0000000..1af88e6
--- /dev/null
+++ b/CursorLang.Tests/EndToEndTests.cs
@@ -0,0 +1,153 @@
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+
+namespace CursorLang.Tests;
+
+///
+/// The application as a whole: the start, the single instance and the exit.
+///
+///
+/// The application cannot be built inside the tests — it raises windows 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.
+///
+public sealed class EndToEndTests
+{
+ private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
+
+ [Fact]
+ public void The_application_starts_and_shows_the_settings_window()
+ {
+ using Launch launch = Launch.Start();
+
+ Assert.NotEqual(IntPtr.Zero, launch.WaitForWindow());
+ Assert.False(launch.Process.HasExited);
+ }
+
+ // A second run raises no second window but shows the window of the running one
+ [Fact]
+ public void The_second_run_ends_by_itself()
+ {
+ using Launch launch = Launch.Start();
+ launch.WaitForWindow();
+
+ 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);
+ }
+
+ [Fact]
+ public void Closing_the_window_ends_the_application()
+ {
+ using Launch launch = Launch.Start();
+ launch.WaitForWindow();
+
+ Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
+ Assert.True(launch.Process.WaitForExit(ExitTimeout), "the application did not end after the window closed");
+ Assert.Equal(0, launch.Process.ExitCode);
+ }
+
+ /// A started application that shuts down together with the check.
+ private sealed class Launch : IDisposable
+ {
+ private Launch(Process process) => Process = process;
+
+ internal Process Process { get; }
+
+ /// Starts the application first — making sure the place is free.
+ internal static Launch Start()
+ {
+ if (Process.GetProcessesByName("CursorLang").Length > 0)
+ {
+ Assert.Skip("The application is already running — this check keeps out of someone else's run");
+ }
+
+ return new Launch(StartProcess());
+ }
+
+ /// Starts the application the way the user does.
+ internal static Process StartProcess()
+ {
+ string path = ExecutablePath();
+
+ if (!File.Exists(path))
+ {
+ Assert.Skip($"The application is not built: {path}");
+ }
+
+ return Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })!;
+ }
+
+ ///
+ /// Waits for the settings window: by the time it appears the application
+ /// has raised its whole cast.
+ ///
+ internal IntPtr WaitForWindow()
+ {
+ DateTime deadline = DateTime.UtcNow + StartTimeout;
+
+ while (DateTime.UtcNow < deadline)
+ {
+ Process.Refresh();
+
+ if (Process.HasExited)
+ {
+ Assert.Fail($"The application exited while starting with code {Process.ExitCode}");
+ }
+
+ if (Process.MainWindowHandle != IntPtr.Zero)
+ {
+ return Process.MainWindowHandle;
+ }
+
+ Thread.Sleep(100);
+ }
+
+ Assert.Fail("The settings window never appeared");
+ return IntPtr.Zero;
+ }
+
+ private static string ExecutablePath()
+ {
+ string configured = Assembly.GetExecutingAssembly()
+ .GetCustomAttributes()
+ .Single(attribute => attribute.Key == "CursorLangExecutable")
+ .Value!;
+
+ return Path.GetFullPath(configured);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (!Process.HasExited)
+ {
+ // The polite way first — that way the app gets to save its settings
+ if (!Process.CloseMainWindow() || !Process.WaitForExit(ExitTimeout))
+ {
+ Process.Kill(entireProcessTree: true);
+ Process.WaitForExit(ExitTimeout);
+ }
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // The process has already ended on its own
+ }
+ finally
+ {
+ Process.Dispose();
+ }
+ }
+ }
+}
diff --git a/CursorLang.Tests/GlobalUsings.cs b/CursorLang.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/CursorLang.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/CursorLang.Tests/Infrastructure/FakeHttp.cs b/CursorLang.Tests/Infrastructure/FakeHttp.cs
new file mode 100644
index 0000000..9e8cb96
--- /dev/null
+++ b/CursorLang.Tests/Infrastructure/FakeHttp.cs
@@ -0,0 +1,41 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
+
+namespace CursorLang.Tests.Infrastructure;
+
+///
+/// The network as the test writes it: the answer is decided here rather than
+/// by a repository somewhere.
+///
+internal sealed class FakeHttpHandler : HttpMessageHandler
+{
+ private readonly Func _reply;
+
+ internal FakeHttpHandler(Func reply) => _reply = reply;
+
+ internal List Requests { get; } = [];
+
+ internal static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ });
+
+ internal static FakeHttpHandler Status(HttpStatusCode status) =>
+ new(_ => new HttpResponseMessage(status));
+
+ internal static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(content),
+ });
+
+ internal HttpClient CreateClient() => new(this);
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ Requests.Add(request);
+ return Task.FromResult(_reply(request));
+ }
+}
diff --git a/CursorLang.Tests/Infrastructure/Fakes.cs b/CursorLang.Tests/Infrastructure/Fakes.cs
new file mode 100644
index 0000000..5c0ab16
--- /dev/null
+++ b/CursorLang.Tests/Infrastructure/Fakes.cs
@@ -0,0 +1,289 @@
+using System.ComponentModel;
+using System.Net.Http;
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.ViewModels;
+
+namespace CursorLang.Tests.Infrastructure;
+
+///
+/// Parts every test needs but few tests care about.
+///
+internal static class Fake
+{
+ internal static UpdateViewModel Updates() =>
+ new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
+}
+
+///
+/// The keyboard layout, with the test in charge of it.
+///
+internal sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
+{
+ internal int StartCalls { get; private set; }
+
+ internal int StopCalls { get; private set; }
+
+ internal int SwitchCalls { get; private set; }
+
+ internal KeyboardLayout CurrentLayout { get; set; } = KeyboardLayout.FromLocaleId(0x0409);
+
+ public KeyboardLayout Current => CurrentLayout;
+
+ public event EventHandler? LayoutChanged;
+
+ public void Start() => StartCalls++;
+
+ public void Stop() => StopCalls++;
+
+ public void SwitchToNext() => SwitchCalls++;
+
+ internal void RaiseLayoutChanged(KeyboardLayout layout, LayoutChangeReason reason) =>
+ LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(layout, reason));
+
+ internal bool HasSubscribers => LayoutChanged is not null;
+}
+
+///
+/// A tooltip that pops up nowhere and merely remembers what it was asked for.
+///
+internal sealed class FakeLayoutPopupService : ILayoutPopupService
+{
+ internal List Shown { get; } = [];
+
+ internal List ShownUntilHidden { get; } = [];
+
+ internal int HideCalls { get; private set; }
+
+ public void Show(KeyboardLayout layout) => Shown.Add(layout);
+
+ public void ShowUntilHidden(KeyboardLayout layout) => ShownUntilHidden.Add(layout);
+
+ public void Hide() => HideCalls++;
+}
+
+///
+/// The Caps Lock interception without intercepting anything: the test supplies the presses.
+///
+internal sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
+{
+ public event EventHandler? Tapped;
+
+ public event EventHandler? HoldStarted;
+
+ public event EventHandler? HoldEnded;
+
+ public bool IsRunning { get; private set; }
+
+ internal int StartCalls { get; private set; }
+
+ internal int StopCalls { get; private set; }
+
+ public void Start()
+ {
+ StartCalls++;
+ IsRunning = true;
+ }
+
+ public void Stop()
+ {
+ StopCalls++;
+ IsRunning = false;
+ }
+
+ internal void RaiseTapped() => Tapped?.Invoke(this, EventArgs.Empty);
+
+ internal void RaiseHoldStarted() => HoldStarted?.Invoke(this, EventArgs.Empty);
+
+ internal void RaiseHoldEnded() => HoldEnded?.Invoke(this, EventArgs.Empty);
+
+ internal bool HasSubscribers => Tapped is not null || HoldStarted is not null || HoldEnded is not null;
+}
+
+///
+/// Startup whose state the test assigns.
+///
+internal sealed class FakeStartupService : IStartupService
+{
+ internal StartupState State { get; set; } = StartupState.Disabled;
+
+ internal StartupState? AnswerOnEnable { get; set; }
+
+ internal List Requests { get; } = [];
+
+ internal int GetStateCalls { get; private set; }
+
+ public Task GetStateAsync()
+ {
+ GetStateCalls++;
+ return Task.FromResult(State);
+ }
+
+ public Task SetEnabledAsync(bool enabled)
+ {
+ Requests.Add(enabled);
+
+ State = enabled
+ ? AnswerOnEnable ?? StartupState.Enabled
+ : StartupState.Disabled;
+
+ return Task.FromResult(State);
+ }
+}
+
+///
+/// Releases the test writes itself, with no repository behind them.
+///
+internal sealed class FakeUpdateService : IUpdateService
+{
+ internal ReleaseInfo? Release { get; set; }
+
+ internal Exception? Failure { get; set; }
+
+ internal TaskCompletionSource? DownloadGate { get; set; }
+
+ internal string PackagePath { get; set; } = string.Empty;
+
+ internal int CheckCalls { get; private set; }
+
+ internal List Installed { get; } = [];
+
+ public bool IsSupported { get; set; } = true;
+
+ public Version CurrentVersion { get; set; } = new(1, 0, 0, 0);
+
+ public Task CheckAsync(CancellationToken cancellationToken)
+ {
+ CheckCalls++;
+
+ return Failure is null
+ ? Task.FromResult(Release)
+ : Task.FromException(Failure);
+ }
+
+ public async Task DownloadAsync(
+ ReleaseInfo release,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ if (DownloadGate is not null)
+ {
+ await DownloadGate.Task.WaitAsync(cancellationToken);
+ }
+
+ if (Failure is not null)
+ {
+ throw Failure;
+ }
+
+ progress?.Report(0.5);
+ return PackagePath;
+ }
+
+ public void Install(string packagePath) => Installed.Add(packagePath);
+}
+
+///
+/// A release list the test fills in, with no repository behind it.
+///
+internal sealed class FakeReleaseFeed : IReleaseFeed
+{
+ internal ReleaseInfo? Release { get; set; }
+
+ internal Exception? Failure { get; set; }
+
+ internal List Authorized { get; } = [];
+
+ public Task GetLatestAsync(CancellationToken cancellationToken) =>
+ Failure is null ? Task.FromResult(Release) : Task.FromException(Failure);
+
+ public void Authorize(HttpRequestMessage request) => Authorized.Add(request);
+}
+
+///
+/// Interface strings without resources: the key comes back as is, tagged with the language.
+///
+internal sealed class FakeLocalizationService : ILocalizationService
+{
+ private string _currentLanguage = "en";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ internal List RequestedKeys { get; } = [];
+
+ public string this[string key]
+ {
+ get
+ {
+ RequestedKeys.Add(key);
+ return $"{_currentLanguage}:{key}";
+ }
+ }
+
+ public IReadOnlyList AvailableLanguages { get; } =
+ [
+ new("en", "English"),
+ new("ru", "Русский"),
+ ];
+
+ public string CurrentLanguage
+ {
+ get => _currentLanguage;
+ set
+ {
+ if (string.IsNullOrWhiteSpace(value) || value == _currentLanguage)
+ {
+ return;
+ }
+
+ _currentLanguage = value;
+
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentLanguage)));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(System.Windows.Data.Binding.IndexerName));
+ }
+ }
+}
+
+///
+/// A theme that paints nothing and only remembers the windows attached to it.
+///
+internal sealed class FakeThemeService : IThemeService
+{
+ public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
+
+ internal List Registered { get; } = [];
+
+ public void Register(System.Windows.Window window) => Registered.Add(window);
+}
+
+///
+/// A tooltip window that shows nothing.
+///
+internal sealed class FakeLayoutPopupWindow : ILayoutPopupWindow
+{
+ internal int ShowCalls { get; private set; }
+
+ internal int HideCalls { get; private set; }
+
+ internal int CloseCalls { get; private set; }
+
+ internal List Calls { get; } = [];
+
+ public void ShowPopup()
+ {
+ ShowCalls++;
+ Calls.Add("show");
+ }
+
+ public void Hide()
+ {
+ HideCalls++;
+ Calls.Add("hide");
+ }
+
+ public void Close()
+ {
+ CloseCalls++;
+ Calls.Add("close");
+ }
+}
diff --git a/CursorLang.Tests/Infrastructure/Sta.cs b/CursorLang.Tests/Infrastructure/Sta.cs
new file mode 100644
index 0000000..9ce03c1
--- /dev/null
+++ b/CursorLang.Tests/Infrastructure/Sta.cs
@@ -0,0 +1,179 @@
+using System.Windows;
+using System.Windows.Threading;
+
+namespace CursorLang.Tests.Infrastructure;
+
+///
+/// The user interface thread for the tests.
+///
+///
+/// Half of the application — windows, the dispatcher and the timers on it —
+/// only works on an STA thread with a message queue, while tests run on a pool
+/// thread. The thread is therefore started once for the whole run: a process
+/// may hold only one , and recreating it between tests
+/// is not possible.
+///
+/// The queue on that thread is pumped for real, so timers fire on their own:
+/// the test only has to await the consequences through .
+///
+internal static class Sta
+{
+ private static readonly Lock Gate = new();
+
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
+
+ internal static Dispatcher Dispatcher
+ {
+ get
+ {
+ lock (Gate)
+ {
+ return field ??= Start();
+ }
+ }
+ }
+
+ /// Runs an action on the interface thread and waits for it to finish.
+ internal static void Run(Action action) => Dispatcher.Invoke(action);
+
+ /// The same for an action that returns a result.
+ internal static TResult Run(Func action) => Dispatcher.Invoke(action);
+
+ ///
+ /// Runs an action on a separate STA thread and waits for it to finish.
+ ///
+ ///
+ /// Needed where the foreignness of the thread is the point: kernel objects
+ /// such as a mutex let their own owner in again, so a "second instance" of
+ /// the application on the same thread does not count as second.
+ ///
+ internal static void RunApart(Action action)
+ {
+ Exception? failure = null;
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ action();
+ }
+ catch (Exception e)
+ {
+ failure = e;
+ }
+ })
+ {
+ IsBackground = true,
+ Name = "CursorLang.Tests apart",
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ if (failure is not null)
+ {
+ throw new InvalidOperationException("The action on the separate thread failed", failure);
+ }
+ }
+
+ ///
+ /// Waits until the dispatcher queue drains: calls deferred through
+ /// BeginInvoke have run by that time.
+ ///
+ internal static void Drain() =>
+ Dispatcher.Invoke(static () => { }, DispatcherPriority.ApplicationIdle);
+
+ ///
+ /// Waits for a condition without getting in the way of the timers.
+ ///
+ ///
+ /// Waiting is allowed from any thread, including the interface thread
+ /// itself: there a plain wait would stop the message queue, and with it
+ /// everything being waited for. On the interface thread the queue therefore
+ /// keeps being pumped by a nested loop.
+ ///
+ internal static void WaitFor(Func condition, string because, TimeSpan? timeout = null)
+ {
+ DateTime deadline = DateTime.UtcNow + (timeout ?? DefaultTimeout);
+
+ while (!condition())
+ {
+ Assert.True(DateTime.UtcNow < deadline, $"Waited in vain: {because}");
+ Idle(TimeSpan.FromMilliseconds(5));
+ }
+ }
+
+ ///
+ /// Waits for the given time while still pumping the queue: that is how
+ /// "nothing happened during this time" is verified.
+ ///
+ internal static void Pause(TimeSpan duration)
+ {
+ Idle(duration);
+ Drain();
+ }
+
+ // A wait during which the dispatcher queue gets its chance to run
+ private static void Idle(TimeSpan duration)
+ {
+ if (Dispatcher.CheckAccess())
+ {
+ var frame = new DispatcherFrame();
+ var timer = new DispatcherTimer(
+ duration,
+ DispatcherPriority.Background,
+ (_, _) => frame.Continue = false,
+ Dispatcher);
+
+ try
+ {
+ Dispatcher.PushFrame(frame);
+ }
+ finally
+ {
+ timer.Stop();
+ }
+
+ return;
+ }
+
+ Thread.Sleep(duration);
+ }
+
+ private static Dispatcher Start()
+ {
+ var ready = new TaskCompletionSource();
+
+ var thread = new Thread(() =>
+ {
+ Dispatcher dispatcher = Dispatcher.CurrentDispatcher;
+
+ // The application has no reason to shut down after its windows:
+ // tests open and close them by the dozen
+ var application = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
+
+ // The shared style dictionary is merged by App.xaml, which the tests
+ // do not have. Without it the settings window still builds, but not
+ // the way the user will see it
+ application.Resources.MergedDictionaries.Add(new ResourceDictionary
+ {
+ Source = new Uri(
+ "pack://application:,,,/CursorLang;component/Themes/Controls.xaml",
+ UriKind.Absolute),
+ });
+
+ ready.SetResult(dispatcher);
+ Dispatcher.Run();
+ })
+ {
+ IsBackground = true,
+ Name = "CursorLang.Tests UI",
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+
+ return ready.Task.GetAwaiter().GetResult();
+ }
+}
diff --git a/CursorLang.Tests/Infrastructure/TempFolder.cs b/CursorLang.Tests/Infrastructure/TempFolder.cs
new file mode 100644
index 0000000..78c4cba
--- /dev/null
+++ b/CursorLang.Tests/Infrastructure/TempFolder.cs
@@ -0,0 +1,39 @@
+using System.IO;
+
+namespace CursorLang.Tests.Infrastructure;
+
+///
+/// An empty folder for the lifetime of one test. Settings live in a file, and
+/// working with that file has to be verified where nothing is worth losing.
+///
+internal sealed class TempFolder : IDisposable
+{
+ internal TempFolder()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ "CursorLang.Tests",
+ Guid.NewGuid().ToString("N"));
+
+ Directory.CreateDirectory(Path);
+ }
+
+ internal string Path { get; }
+
+ internal string File(string name) => System.IO.Path.Combine(Path, name);
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ // Litter in the temp folder is no reason to fail a test that passed
+ }
+ }
+}
diff --git a/CursorLang.Tests/Infrastructure/TempRegistryKey.cs b/CursorLang.Tests/Infrastructure/TempRegistryKey.cs
new file mode 100644
index 0000000..39379ee
--- /dev/null
+++ b/CursorLang.Tests/Infrastructure/TempRegistryKey.cs
@@ -0,0 +1,36 @@
+using Microsoft.Win32;
+
+namespace CursorLang.Tests.Infrastructure;
+
+///
+/// A registry key of one test's own. Startup outside a package lives in the
+/// registry, and the real startup list of the user is no place to experiment.
+///
+internal sealed class TempRegistryKey : IDisposable
+{
+ private const string Parent = @"Software\CursorLang.Tests";
+
+ private readonly string _path;
+
+ internal TempRegistryKey()
+ {
+ _path = $@"{Parent}\{Guid.NewGuid():N}";
+ Key = Registry.CurrentUser.CreateSubKey(_path);
+ }
+
+ internal RegistryKey Key { get; }
+
+ public void Dispose()
+ {
+ Key.Dispose();
+
+ try
+ {
+ Registry.CurrentUser.DeleteSubKeyTree(_path, throwOnMissingSubKey: false);
+ }
+ catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException)
+ {
+ // A leftover key is no reason to fail a test that passed
+ }
+ }
+}
diff --git a/CursorLang.Tests/Interop/CaretNativeTests.cs b/CursorLang.Tests/Interop/CaretNativeTests.cs
new file mode 100644
index 0000000..2d8a696
--- /dev/null
+++ b/CursorLang.Tests/Interop/CaretNativeTests.cs
@@ -0,0 +1,106 @@
+using CursorLang.Interop;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Interop;
+
+///
+/// Vetting the caret position. Some applications report it in their own
+/// coordinate system, and such answers have to be sifted out by the bounds
+/// of the input window.
+///
+public sealed class CaretNativeTests
+{
+ private static readonly PopupWindowNative.Rect Window = new()
+ {
+ Left = 100,
+ Top = 100,
+ Right = 900,
+ Bottom = 700,
+ };
+
+ [Fact]
+ public void A_caret_inside_the_window_is_taken_as_is()
+ {
+ var caret = new PopupWindowNative.Rect { Left = 200, Top = 300, Right = 202, Bottom = 320 };
+
+ Assert.Equal(caret, CaretNative.Validate(caret, Window, scale: 1.5));
+ }
+
+ // The application reported the coordinates without the screen scale: on
+ // their own they sit above and to the left of the input window, and after
+ // being brought to the scale they land inside it
+ [Fact]
+ public void An_unscaled_caret_is_brought_to_the_screen_scale()
+ {
+ var caret = new PopupWindowNative.Rect { Left = 80, Top = 80, Right = 81, Bottom = 90 };
+
+ PopupWindowNative.Rect? validated = CaretNative.Validate(caret, Window, scale: 1.5);
+
+ Assert.NotNull(validated);
+ Assert.Equal(120, validated.Value.Left);
+ Assert.Equal(120, validated.Value.Top);
+ Assert.Equal(121, validated.Value.Right);
+ Assert.Equal(135, validated.Value.Bottom);
+ }
+
+ [Fact]
+ public void A_caret_far_from_the_window_is_discarded()
+ {
+ var caret = new PopupWindowNative.Rect { Left = 5000, Top = 5000, Right = 5002, Bottom = 5020 };
+
+ Assert.Null(CaretNative.Validate(caret, Window, scale: 1.5));
+ }
+
+ // At the ordinary scale there is nothing to fix: wrong coordinates stay wrong
+ [Fact]
+ public void At_scale_one_a_caret_outside_the_window_is_discarded()
+ {
+ var caret = new PopupWindowNative.Rect { Left = 10, Top = 10, Right = 12, Bottom = 30 };
+
+ Assert.Null(CaretNative.Validate(caret, Window, scale: 1.0));
+ }
+
+ [Theory]
+ [InlineData(100, 100, 900, 700, true)]
+ [InlineData(99, 100, 900, 700, false)]
+ [InlineData(100, 99, 900, 700, false)]
+ [InlineData(100, 100, 901, 700, false)]
+ [InlineData(100, 100, 900, 701, false)]
+ [InlineData(400, 400, 402, 420, true)]
+ public void Inside_the_window_means_entirely_inside(
+ int left, int top, int right, int bottom, bool expected)
+ {
+ var caret = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
+
+ Assert.Equal(expected, CaretNative.IsInside(caret, Window));
+ }
+
+ // When there is no caret, its rectangle comes back with zero height. Zero
+ // coordinates, on the other hand, are the ordinary start of an empty field
+ [Theory]
+ [InlineData(0, 0, 0, 0, true)]
+ [InlineData(0, 0, 2, 0, true)]
+ [InlineData(0, 10, 2, 5, true)]
+ [InlineData(0, 0, 0, 1, false)]
+ [InlineData(0, 0, 0, 20, false)]
+ public void A_caret_without_height_counts_as_empty(
+ int left, int top, int right, int bottom, bool expected)
+ {
+ var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
+
+ Assert.Equal(expected, CaretNative.IsEmpty(rect));
+ }
+
+ // The answer depends on what is on screen right now, but it has no right to
+ // throw: the tooltip is shown whatever the answer
+ [Fact]
+ public void Asking_the_system_for_the_caret_goes_without_errors()
+ {
+ PopupWindowNative.Rect? caret = Sta.Run(CaretNative.TryGetCaretRect);
+
+ if (caret is not null)
+ {
+ Assert.True(caret.Value.Bottom > caret.Value.Top);
+ }
+ }
+}
diff --git a/CursorLang.Tests/Interop/ForegroundWindowTests.cs b/CursorLang.Tests/Interop/ForegroundWindowTests.cs
new file mode 100644
index 0000000..6a7c2f7
--- /dev/null
+++ b/CursorLang.Tests/Interop/ForegroundWindowTests.cs
@@ -0,0 +1,181 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Interop;
+using CursorLang.Interop;
+using CursorLang.Models;
+using CursorLang.Tests.Infrastructure;
+using CursorLang.ViewModels;
+using CursorLang.Views;
+
+namespace CursorLang.Tests.Interop;
+
+///
+/// Checks that need a real foreground window with an input field: the caret
+/// and the layout switch live exactly there.
+///
+///
+/// Windows does not always allow a window to come forward — when the screen is
+/// locked, say, or when the run happens in a session without a desktop. In
+/// those cases the check reports itself as skipped rather than failed: there
+/// would be nothing to verify.
+///
+public sealed class ForegroundWindowTests
+{
+ [Fact]
+ public void The_caret_in_an_input_field_is_found()
+ {
+ Sta.Run(() =>
+ {
+ using var input = new InputWindow();
+ input.RequireForeground();
+
+ PopupWindowNative.Rect? caret = CaretNative.TryGetCaretRect();
+
+ if (caret is null)
+ {
+ Assert.Skip("The input field did not report the caret position");
+ }
+
+ // The caret has to sit inside the input window and to have a height
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
+
+ Assert.True(caret.Value.Bottom > caret.Value.Top);
+ Assert.InRange(caret.Value.Left, bounds.Left, bounds.Right);
+ Assert.InRange(caret.Value.Top, bounds.Top, bounds.Bottom);
+ });
+ }
+
+ [Fact]
+ public void The_tooltip_at_the_caret_lands_next_to_it()
+ {
+ Sta.Run(() =>
+ {
+ using var input = new InputWindow();
+ input.RequireForeground();
+
+ PopupWindowNative.Rect? caret = CaretNative.TryGetCaretRect();
+ if (caret is null)
+ {
+ Assert.Skip("The input field did not report the caret position");
+ }
+
+ var settings = new AppSettings
+ {
+ PlacementMode = PopupPlacementMode.AtCaret,
+ CaretSide = AnchorSide.BottomRight,
+ CaretOffset = 8,
+ };
+
+ var viewModel = new LayoutPopupViewModel(settings) { ShortName = "RU" };
+ var popup = new LayoutPopupWindow(viewModel, settings);
+
+ try
+ {
+ popup.ShowPopup();
+
+ PopupWindowNative.Rect bounds =
+ WindowPlacementNative.TryGetBounds(new WindowInteropHelper(popup).Handle)!.Value;
+
+ // The tooltip landed to the right of and below the caret — as asked
+ Assert.True(bounds.Left >= caret.Value.Right);
+ Assert.True(bounds.Top >= caret.Value.Bottom);
+ }
+ finally
+ {
+ popup.Close();
+ }
+ });
+ }
+
+ // The request to switch the layout goes to the window holding the input
+ // focus, so it only concerns the test window itself
+ [Fact]
+ public void The_request_to_change_the_layout_reaches_its_own_window()
+ {
+ Sta.Run(() =>
+ {
+ using var input = new InputWindow();
+ input.RequireForeground();
+
+ int before = KeyboardLayoutNative.GetActiveLocaleId();
+
+ KeyboardLayoutNative.RequestNextLayout();
+ Sta.Pause(TimeSpan.FromMilliseconds(150));
+
+ int after = KeyboardLayoutNative.GetActiveLocaleId();
+
+ Assert.InRange(after, 1, 0xFFFF);
+
+ if (after == before)
+ {
+ // The system may hold a single layout — there is nothing to switch to
+ return;
+ }
+
+ // Bring the layout back around the circle to where it was
+ for (int i = 0; i < 8 && KeyboardLayoutNative.GetActiveLocaleId() != before; i++)
+ {
+ KeyboardLayoutNative.RequestNextLayout();
+ Sta.Pause(TimeSpan.FromMilliseconds(150));
+ }
+ });
+ }
+
+ /// A window with an input field brought to the foreground.
+ private sealed class InputWindow : IDisposable
+ {
+ private readonly Window _window;
+
+ internal InputWindow()
+ {
+ var box = new TextBox { Text = "check", FontSize = 20 };
+
+ _window = new Window
+ {
+ Width = 400,
+ Height = 200,
+ ShowInTaskbar = false,
+ WindowStartupLocation = WindowStartupLocation.Manual,
+ Left = 100,
+ Top = 100,
+ Topmost = true,
+ Content = box,
+ };
+
+ _window.Show();
+ Handle = new WindowInteropHelper(_window).Handle;
+
+ // Windows grants the right to bring a window forward neither to
+ // everyone nor at once, so it takes a few attempts
+ for (int attempt = 0; attempt < 10; attempt++)
+ {
+ _window.Activate();
+ box.Focus();
+ box.CaretIndex = box.Text.Length;
+
+ Sta.Pause(TimeSpan.FromMilliseconds(50));
+
+ if (KeyboardLayoutNative.GetForegroundWindow() == Handle)
+ {
+ break;
+ }
+ }
+
+ // The caret does not appear at the same instant as the focus
+ Sta.Pause(TimeSpan.FromMilliseconds(100));
+ }
+
+ internal IntPtr Handle { get; }
+
+ /// Skips the check if the window never became the foreground one.
+ internal void RequireForeground()
+ {
+ if (KeyboardLayoutNative.GetForegroundWindow() != Handle)
+ {
+ Assert.Skip("The window could not be brought to the foreground");
+ }
+ }
+
+ public void Dispose() => _window.Close();
+ }
+}
diff --git a/CursorLang.Tests/Interop/LowLevelKeyboardHookTests.cs b/CursorLang.Tests/Interop/LowLevelKeyboardHookTests.cs
new file mode 100644
index 0000000..2216608
--- /dev/null
+++ b/CursorLang.Tests/Interop/LowLevelKeyboardHookTests.cs
@@ -0,0 +1,209 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+using CursorLang.Interop;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Interop;
+
+///
+/// Making sense of the events of the system keyboard hook.
+///
+///
+/// The events are fed straight into the handler the way Windows sends them:
+/// the tests have no right to press keys for real — the interception is shared
+/// by the whole system, and a real press would land in someone else's window.
+///
+public sealed class LowLevelKeyboardHookTests
+{
+ private const int HcAction = 0;
+ private const int WmKeyDown = 0x0100;
+ private const int WmKeyUp = 0x0101;
+ private const int WmSysKeyDown = 0x0104;
+ private const int WmSysKeyUp = 0x0105;
+ private const int WmMouseMove = 0x0200;
+
+ private const uint Injected = 0x10;
+ private const int CapsLock = 0x14;
+
+ [Theory]
+ [InlineData(WmKeyDown, true)]
+ [InlineData(WmSysKeyDown, true)]
+ [InlineData(WmKeyUp, false)]
+ [InlineData(WmSysKeyUp, false)]
+ public void Presses_and_releases_reach_the_handler(int message, bool expectedKeyDown)
+ {
+ List<(int Key, bool IsDown)> events = [];
+ var hook = new LowLevelKeyboardHook((key, isDown) =>
+ {
+ events.Add((key, isDown));
+ return false;
+ });
+
+ using (hook)
+ {
+ Send(hook, HcAction, message, CapsLock, flags: 0);
+ }
+
+ Assert.Equal([(CapsLock, expectedKeyDown)], events);
+ }
+
+ [Fact]
+ public void A_swallowed_event_goes_no_further()
+ {
+ using var hook = new LowLevelKeyboardHook(static (_, _) => true);
+
+ IntPtr result = Send(hook, HcAction, WmKeyDown, CapsLock, flags: 0);
+
+ // A non-zero answer breaks the chain: neither the application nor the
+ // case handler in Windows will see the event
+ Assert.Equal(new IntPtr(1), result);
+ }
+
+ // Synthetic input comes from on-screen keyboards and automation tools
+ [Fact]
+ public void Synthetic_input_is_not_intercepted()
+ {
+ List keys = [];
+ using var hook = new LowLevelKeyboardHook((key, _) =>
+ {
+ keys.Add(key);
+ return true;
+ });
+
+ IntPtr result = Send(hook, HcAction, WmKeyDown, CapsLock, Injected);
+
+ Assert.Empty(keys);
+ Assert.NotEqual(new IntPtr(1), result);
+ }
+
+ // Windows asks for events below zero not to be inspected but simply passed on
+ [Fact]
+ public void Events_not_meant_for_inspection_are_passed_on()
+ {
+ List keys = [];
+ using var hook = new LowLevelKeyboardHook((key, _) =>
+ {
+ keys.Add(key);
+ return true;
+ });
+
+ Send(hook, code: -1, WmKeyDown, CapsLock, flags: 0);
+
+ Assert.Empty(keys);
+ }
+
+ [Fact]
+ public void The_other_messages_are_not_shown_to_the_handler()
+ {
+ List keys = [];
+ using var hook = new LowLevelKeyboardHook((key, _) =>
+ {
+ keys.Add(key);
+ return true;
+ });
+
+ Send(hook, HcAction, WmMouseMove, CapsLock, flags: 0);
+
+ Assert.Empty(keys);
+ }
+
+ [Fact]
+ public void The_handler_sees_the_code_of_the_pressed_key()
+ {
+ List keys = [];
+ using var hook = new LowLevelKeyboardHook((key, _) =>
+ {
+ keys.Add(key);
+ return false;
+ });
+
+ Send(hook, HcAction, WmKeyDown, virtualKey: 0x41, flags: 0);
+ Send(hook, HcAction, WmKeyDown, virtualKey: 0x1B, flags: 0);
+
+ Assert.Equal([0x41, 0x1B], keys);
+ }
+
+ [Fact]
+ public void The_interception_is_installed_and_removed()
+ {
+ Sta.Run(() =>
+ {
+ using var hook = new LowLevelKeyboardHook(static (_, _) => false);
+
+ Assert.False(hook.IsInstalled);
+
+ Assert.True(hook.Install());
+ Assert.True(hook.IsInstalled);
+
+ hook.Uninstall();
+ Assert.False(hook.IsInstalled);
+ });
+ }
+
+ [Fact]
+ public void Installing_again_changes_nothing()
+ {
+ Sta.Run(() =>
+ {
+ using var hook = new LowLevelKeyboardHook(static (_, _) => false);
+
+ Assert.True(hook.Install());
+ Assert.True(hook.Install());
+ Assert.True(hook.IsInstalled);
+
+ hook.Uninstall();
+ });
+ }
+
+ [Fact]
+ public void Removing_without_installing_passes_silently()
+ {
+ var hook = new LowLevelKeyboardHook(static (_, _) => false);
+
+ hook.Uninstall();
+ hook.Uninstall();
+
+ Assert.False(hook.IsInstalled);
+ }
+
+ [Fact]
+ public void Closing_removes_the_interception()
+ {
+ Sta.Run(() =>
+ {
+ var hook = new LowLevelKeyboardHook(static (_, _) => false);
+ hook.Install();
+
+ hook.Dispose();
+
+ Assert.False(hook.IsInstalled);
+ });
+ }
+
+ // The event arrives from Windows as a structure in unmanaged memory
+ private static IntPtr Send(
+ LowLevelKeyboardHook hook, int code, int message, int virtualKey, uint flags)
+ {
+ // vkCode, scanCode, flags and time take four bytes each, then a pointer
+ const int Size = 24;
+ IntPtr data = Marshal.AllocHGlobal(Size);
+
+ try
+ {
+ Marshal.WriteInt32(data, 0, virtualKey);
+ Marshal.WriteInt32(data, 4, 0);
+ Marshal.WriteInt32(data, 8, (int)flags);
+ Marshal.WriteInt32(data, 12, 0);
+ Marshal.WriteIntPtr(data, 16, IntPtr.Zero);
+
+ MethodInfo handler = typeof(LowLevelKeyboardHook)
+ .GetMethod("OnHookEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ return (IntPtr)handler.Invoke(hook, [code, new IntPtr(message), data])!;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(data);
+ }
+ }
+}
diff --git a/CursorLang.Tests/Interop/NativeWrappersTests.cs b/CursorLang.Tests/Interop/NativeWrappersTests.cs
new file mode 100644
index 0000000..79c6374
--- /dev/null
+++ b/CursorLang.Tests/Interop/NativeWrappersTests.cs
@@ -0,0 +1,243 @@
+using System.Windows;
+using System.Windows.Interop;
+using CursorLang.Interop;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Interop;
+
+///
+/// The Win32 wrappers: what is checked is that the calls are put together
+/// right — structures of the expected size, flags in place, and the answers
+/// of the system read correctly.
+///
+public sealed class NativeWrappersTests
+{
+ [Fact]
+ public void The_cursor_position_is_read()
+ {
+ PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
+
+ // The virtual screen may run into negative coordinates, but not beyond
+ // reason: a misread structure would give garbage
+ Assert.InRange(cursor.X, -32_000, 32_000);
+ Assert.InRange(cursor.Y, -32_000, 32_000);
+ }
+
+ [Fact]
+ public void The_scale_of_the_monitor_under_the_cursor_is_positive()
+ {
+ double scale = PopupWindowNative.GetScaleAt(PopupWindowNative.GetCursorPosition());
+
+ Assert.InRange(scale, 0.5, 8.0);
+ }
+
+ [Fact]
+ public void The_work_area_of_the_active_monitor_is_not_empty()
+ {
+ (PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
+
+ Assert.True(work.Right > work.Left);
+ Assert.True(work.Bottom > work.Top);
+ Assert.InRange(scale, 0.5, 8.0);
+ }
+
+ [Fact]
+ public void The_window_bounds_are_read_from_the_system()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+
+ PopupWindowNative.Rect? bounds = WindowPlacementNative.TryGetBounds(window.Handle);
+
+ Assert.NotNull(bounds);
+ Assert.True(bounds.Value.Right > bounds.Value.Left);
+ Assert.True(bounds.Value.Bottom > bounds.Value.Top);
+ });
+ }
+
+ [Fact]
+ public void A_window_that_does_not_exist_has_no_bounds()
+ {
+ Assert.Null(WindowPlacementNative.TryGetBounds(IntPtr.Zero));
+ }
+
+ [Fact]
+ public void A_window_is_moved_to_the_given_point()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+
+ PopupWindowNative.MoveTo(window.Handle, 120, 90);
+
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ Assert.Equal(120, bounds.Left);
+ Assert.Equal(90, bounds.Top);
+ });
+ }
+
+ [Fact]
+ public void Moving_does_not_change_the_window_size()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+
+ PopupWindowNative.Rect before = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ PopupWindowNative.MoveTo(window.Handle, 200, 150);
+ PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+
+ Assert.Equal(before.Right - before.Left, after.Right - after.Left);
+ Assert.Equal(before.Bottom - before.Top, after.Bottom - after.Top);
+ });
+ }
+
+ [Fact]
+ public void The_window_bounds_are_set_as_a_whole()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+
+ PopupWindowNative.SetBounds(window.Handle, 60, 70, 320, 240);
+
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ Assert.Equal(60, bounds.Left);
+ Assert.Equal(70, bounds.Top);
+ Assert.Equal(380, bounds.Right);
+ Assert.Equal(310, bounds.Bottom);
+ });
+ }
+
+ [Fact]
+ public void The_work_area_is_found_by_the_rectangle_of_the_window()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+
+ PopupWindowNative.Rect? work = WindowPlacementNative.TryGetWorkAreaNear(bounds);
+
+ Assert.NotNull(work);
+ Assert.True(work.Value.Right > work.Value.Left);
+ Assert.True(work.Value.Bottom > work.Value.Top);
+ });
+ }
+
+ // The nearest monitor is picked, so an area is found even for a point far off screen
+ [Fact]
+ public void For_a_rectangle_off_every_screen_the_nearest_monitor_is_taken()
+ {
+ var far = new PopupWindowNative.Rect { Left = 30_000, Top = 30_000, Right = 30_100, Bottom = 30_100 };
+
+ PopupWindowNative.Rect? work = WindowPlacementNative.TryGetWorkAreaNear(far);
+
+ Assert.NotNull(work);
+ Assert.True(work.Value.Right > work.Value.Left);
+ }
+
+ [Fact]
+ public void A_window_becomes_invisible_to_the_focus_and_the_switcher()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+
+ PopupWindowNative.MakePassive(window.Handle);
+
+ // Checked through the same wrapper: the style has to stick and not
+ // be reset by a repeated call
+ PopupWindowNative.MakePassive(window.Handle);
+ });
+ }
+
+ [Fact]
+ public void The_layout_of_the_foreground_window_is_read()
+ {
+ int localeId = KeyboardLayoutNative.GetActiveLocaleId();
+
+ // The low word of the HKL is the locale identifier, and it is never zero
+ Assert.NotEqual(0, localeId);
+ Assert.InRange(localeId, 1, 0xFFFF);
+ }
+
+ [Fact]
+ public void The_layout_is_read_for_any_window()
+ {
+ int localeId = KeyboardLayoutNative.GetLocaleIdOf(KeyboardLayoutNative.GetForegroundWindow());
+
+ Assert.InRange(localeId, 0, 0xFFFF);
+ }
+
+ [Fact]
+ public void The_input_state_of_the_foreground_is_read()
+ {
+ bool received = ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info);
+
+ if (received)
+ {
+ // The structure size is filled in by the wrapper itself, and it has
+ // to match what Windows expects
+ Assert.Equal(System.Runtime.InteropServices.Marshal.SizeOf(),
+ info.cbSize);
+ }
+ }
+
+ [Fact]
+ public void The_right_to_show_a_window_is_given_away_without_errors()
+ {
+ ForegroundPermissionNative.GrantToAnyProcess();
+ }
+
+ [Fact]
+ public void The_window_title_bar_is_repainted()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new HandleWindow();
+
+ WindowThemeNative.SetDarkTitleBar(window.Handle, isDark: true);
+ WindowThemeNative.SetDarkTitleBar(window.Handle, isDark: false);
+ });
+ }
+
+ // The window may be gone by the time of the repaint
+ [Fact]
+ public void Repainting_a_window_that_does_not_exist_passes_silently()
+ {
+ WindowThemeNative.SetDarkTitleBar(IntPtr.Zero, isDark: true);
+ }
+
+ [Fact]
+ public void The_package_flag_is_computed_once_and_does_not_change()
+ {
+ bool first = PackageIdentityNative.IsPackaged;
+
+ Assert.Equal(first, PackageIdentityNative.IsPackaged);
+ }
+
+ /// A window with a created handle that never appears on screen.
+ private sealed class HandleWindow : IDisposable
+ {
+ private readonly Window _window;
+
+ internal HandleWindow()
+ {
+ _window = new Window
+ {
+ Width = 300,
+ Height = 200,
+ ShowInTaskbar = false,
+ WindowStartupLocation = WindowStartupLocation.Manual,
+ };
+
+ Handle = new WindowInteropHelper(_window).EnsureHandle();
+ }
+
+ internal IntPtr Handle { get; }
+
+ public void Dispose() => _window.Close();
+ }
+}
diff --git a/CursorLang.Tests/Models/AppSettingsTests.cs b/CursorLang.Tests/Models/AppSettingsTests.cs
new file mode 100644
index 0000000..13db438
--- /dev/null
+++ b/CursorLang.Tests/Models/AppSettingsTests.cs
@@ -0,0 +1,165 @@
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.Json;
+using System.Windows.Media;
+using CursorLang.Models;
+
+namespace CursorLang.Tests.Models;
+
+public sealed class AppSettingsTests
+{
+ [Fact]
+ public void Default_values_describe_a_tooltip_at_the_cursor()
+ {
+ var settings = new AppSettings();
+
+ Assert.Equal("en", settings.Language);
+ Assert.Equal(AppTheme.System, settings.Theme);
+ Assert.Equal(PopupPlacementMode.AtCursor, settings.PlacementMode);
+ Assert.Equal(AnchorSide.BottomRight, settings.CursorSide);
+ Assert.Equal(16, settings.CursorOffset);
+ Assert.Equal(AnchorSide.BottomRight, settings.CaretSide);
+ Assert.Equal(16, settings.CaretOffset);
+ Assert.Equal(ScreenPosition.BottomRight, settings.ScreenPosition);
+ Assert.Equal(24, settings.ScreenMargin);
+ Assert.Equal(20, settings.FontSize);
+ Assert.Equal(0.9, settings.Opacity);
+ Assert.Equal(500, settings.DurationMilliseconds);
+ Assert.Equal(300, settings.CapsLockHoldMilliseconds);
+ Assert.Equal(Color.FromRgb(0x20, 0x20, 0x20), settings.BackgroundColor);
+ Assert.Equal(Color.FromRgb(0xFF, 0xFF, 0xFF), settings.ForegroundColor);
+ }
+
+ // The app must not change how the system behaves until it is asked to
+ [Fact]
+ public void Caps_Lock_interception_is_off_by_default()
+ {
+ Assert.False(new AppSettings().UseCapsLockHotkey);
+ }
+
+ [Fact]
+ public void The_time_on_screen_is_derived_from_milliseconds()
+ {
+ var settings = new AppSettings { DurationMilliseconds = 1250 };
+
+ Assert.Equal(TimeSpan.FromMilliseconds(1250), settings.Duration);
+ }
+
+ [Fact]
+ public void The_hold_threshold_is_derived_from_milliseconds()
+ {
+ var settings = new AppSettings { CapsLockHoldMilliseconds = 400 };
+
+ Assert.Equal(TimeSpan.FromMilliseconds(400), settings.CapsLockHoldDelay);
+ }
+
+ [Theory]
+ [MemberData(nameof(WritableProperties))]
+ public void A_changed_setting_is_announced_to_subscribers(string propertyName)
+ {
+ var settings = new AppSettings();
+ List changed = [];
+ settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ SetDifferentValue(settings, propertyName);
+
+ Assert.Contains(propertyName, changed);
+ }
+
+ [Theory]
+ [MemberData(nameof(WritableProperties))]
+ public void Writing_the_same_value_leaves_subscribers_alone(string propertyName)
+ {
+ var settings = new AppSettings();
+ PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!;
+ object? value = property.GetValue(settings);
+
+ List changed = [];
+ settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ property.SetValue(settings, value);
+
+ Assert.Empty(changed);
+ }
+
+ // Duration and CapsLockHoldDelay are derived from other settings and have
+ // no business being in the file
+ [Fact]
+ public void Derived_values_stay_out_of_the_file()
+ {
+ using JsonDocument document = JsonSerializer.SerializeToDocument(new AppSettings());
+
+ List names = [.. document.RootElement.EnumerateObject().Select(property => property.Name)];
+
+ Assert.DoesNotContain(nameof(AppSettings.Duration), names);
+ Assert.DoesNotContain(nameof(AppSettings.CapsLockHoldDelay), names);
+
+ // What they are derived from, on the other hand, has to be stored
+ Assert.Contains(nameof(AppSettings.DurationMilliseconds), names);
+ Assert.Contains(nameof(AppSettings.CapsLockHoldMilliseconds), names);
+ }
+
+ [Fact]
+ public void Settings_report_changes_as_INotifyPropertyChanged()
+ {
+ Assert.IsAssignableFrom(new AppSettings());
+ }
+
+ public static TheoryData WritableProperties()
+ {
+ var data = new TheoryData();
+
+ foreach (string name in WritablePropertyNames())
+ {
+ data.Add(name);
+ }
+
+ return data;
+ }
+
+ /// Names of the settings the user is able to change.
+ internal static IEnumerable WritablePropertyNames() =>
+ typeof(AppSettings).GetProperties()
+ .Where(property => property.CanWrite)
+ .Select(property => property.Name);
+
+ // A value guaranteed to differ from the current one: each kind of setting
+ // has its own way of differing
+ private static void SetDifferentValue(AppSettings settings, string propertyName)
+ {
+ PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!;
+ object? current = property.GetValue(settings);
+
+ object next = current switch
+ {
+ string text => text + "-other",
+ double number => number + 1,
+ bool flag => !flag,
+ Color color => Color.FromRgb((byte)(color.R + 1), color.G, color.B),
+ Enum value => NextEnumValue(value),
+ DateTimeOffset moment => moment.AddDays(1),
+
+ // A setting never set yet: the app has not checked for updates once
+ null when property.PropertyType == typeof(DateTimeOffset?) => DateTimeOffset.UnixEpoch,
+
+ _ => throw new NotSupportedException($"Unknown kind of setting: {property.PropertyType}"),
+ };
+
+ property.SetValue(settings, next);
+ }
+
+ private static object NextEnumValue(Enum current)
+ {
+ Array values = Enum.GetValues(current.GetType());
+
+ foreach (object? value in values)
+ {
+ if (!Equals(value, current))
+ {
+ return value!;
+ }
+ }
+
+ throw new NotSupportedException($"{current.GetType()} has a single value");
+ }
+}
diff --git a/CursorLang.Tests/Models/KeyboardLayoutTests.cs b/CursorLang.Tests/Models/KeyboardLayoutTests.cs
new file mode 100644
index 0000000..b471f03
--- /dev/null
+++ b/CursorLang.Tests/Models/KeyboardLayoutTests.cs
@@ -0,0 +1,76 @@
+using System.Globalization;
+using CursorLang.Models;
+
+namespace CursorLang.Tests.Models;
+
+public sealed class KeyboardLayoutTests
+{
+ [Theory]
+ [InlineData(0x0409, "EN")]
+ [InlineData(0x0419, "RU")]
+ [InlineData(0x040C, "FR")]
+ [InlineData(0x0407, "DE")]
+ public void The_short_name_comes_from_the_language_code(int localeId, string expected)
+ {
+ KeyboardLayout layout = KeyboardLayout.FromLocaleId(localeId);
+
+ Assert.Equal(expected, layout.ShortName);
+ Assert.Equal(localeId, layout.LocaleId);
+ }
+
+ [Fact]
+ public void The_full_name_joins_the_short_name_and_the_native_language_name()
+ {
+ KeyboardLayout layout = KeyboardLayout.FromLocaleId(0x0419);
+
+ Assert.Equal($"RU — {new CultureInfo(0x0419).NativeName}", layout.DisplayName);
+ }
+
+ // A layout may belong to a language the system does not know — that is not an error
+ [Fact]
+ public void An_unknown_locale_is_shown_by_its_own_code()
+ {
+ int unknown = FindUnknownLocaleId();
+
+ KeyboardLayout layout = KeyboardLayout.FromLocaleId(unknown);
+
+ string expected = $"0x{unknown:X4}";
+ Assert.Equal(expected, layout.ShortName);
+ Assert.Equal(expected, layout.DisplayName);
+ }
+
+ [Fact]
+ public void Layouts_of_the_same_locale_are_equal()
+ {
+ Assert.Equal(KeyboardLayout.FromLocaleId(0x0409), KeyboardLayout.FromLocaleId(0x0409));
+ Assert.NotEqual(KeyboardLayout.FromLocaleId(0x0409), KeyboardLayout.FromLocaleId(0x0419));
+ }
+
+ [Fact]
+ public void A_layout_can_also_be_built_directly()
+ {
+ var layout = new KeyboardLayout(1, "XX", "XX — language");
+
+ Assert.Equal(1, layout.LocaleId);
+ Assert.Equal("XX", layout.ShortName);
+ Assert.Equal("XX — language", layout.DisplayName);
+ }
+
+ // An identifier with no culture behind it in Windows
+ private static int FindUnknownLocaleId()
+ {
+ for (int candidate = 0x1000; candidate <= 0xFFFF; candidate++)
+ {
+ try
+ {
+ _ = new CultureInfo(candidate);
+ }
+ catch (CultureNotFoundException)
+ {
+ return candidate;
+ }
+ }
+
+ throw new InvalidOperationException("The system knows every locale identifier");
+ }
+}
diff --git a/CursorLang.Tests/Models/LayoutChangedEventArgsTests.cs b/CursorLang.Tests/Models/LayoutChangedEventArgsTests.cs
new file mode 100644
index 0000000..9110c7a
--- /dev/null
+++ b/CursorLang.Tests/Models/LayoutChangedEventArgsTests.cs
@@ -0,0 +1,27 @@
+using CursorLang.Models;
+
+namespace CursorLang.Tests.Models;
+
+public sealed class LayoutChangedEventArgsTests
+{
+ [Theory]
+ [InlineData(LayoutChangeReason.UserSwitched)]
+ [InlineData(LayoutChangeReason.ApplicationSwitched)]
+ public void The_event_carries_the_layout_and_the_reason(LayoutChangeReason reason)
+ {
+ KeyboardLayout layout = KeyboardLayout.FromLocaleId(0x0419);
+
+ var args = new LayoutChangedEventArgs(layout, reason);
+
+ Assert.Same(layout, args.Layout);
+ Assert.Equal(reason, args.Reason);
+ }
+
+ [Fact]
+ public void The_event_stays_an_ordinary_dotnet_event()
+ {
+ var args = new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(0x0409), LayoutChangeReason.UserSwitched);
+
+ Assert.IsAssignableFrom(args);
+ }
+}
diff --git a/CursorLang.Tests/Resources/StringsTests.cs b/CursorLang.Tests/Resources/StringsTests.cs
new file mode 100644
index 0000000..cd12217
--- /dev/null
+++ b/CursorLang.Tests/Resources/StringsTests.cs
@@ -0,0 +1,178 @@
+using System.Collections;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using System.Resources;
+using System.Text.RegularExpressions;
+using CursorLang.Models;
+
+namespace CursorLang.Tests.Resources;
+
+///
+/// Checks of the resources themselves: they carry every caption in the settings
+/// window, and a missing key only shows on a live window.
+///
+public sealed partial class StringsTests
+{
+ private static readonly ResourceManager Resources =
+ new("CursorLang.Resources.Strings", typeof(App).Assembly);
+
+ private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en");
+ private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru");
+
+ [Theory]
+ [MemberData(nameof(EnumKeys))]
+ public void Every_list_value_has_an_English_caption(string key)
+ {
+ Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, English)));
+ }
+
+ [Theory]
+ [MemberData(nameof(EnumKeys))]
+ public void Every_list_value_has_a_Russian_caption(string key)
+ {
+ Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, Russian)));
+ }
+
+ [Fact]
+ public void The_Russian_translation_covers_every_string()
+ {
+ List missing = [];
+
+ foreach (string key in NeutralKeys())
+ {
+ // An untranslated resource falls back to English, so the Russian set
+ // is asked directly rather than through the string with a fallback
+ if (RussianSet().GetString(key) is null)
+ {
+ missing.Add(key);
+ }
+ }
+
+ Assert.Empty(missing);
+ }
+
+ [Fact]
+ public void The_Russian_translation_has_no_extra_strings()
+ {
+ HashSet neutral = [.. NeutralKeys()];
+ List extra = [];
+
+ foreach (DictionaryEntry entry in RussianSet())
+ {
+ var key = (string)entry.Key;
+ if (!neutral.Contains(key))
+ {
+ extra.Add(key);
+ }
+ }
+
+ Assert.Empty(extra);
+ }
+
+ [Fact]
+ public void There_are_no_empty_strings_in_the_resources()
+ {
+ foreach (string key in NeutralKeys())
+ {
+ Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, English)), key);
+ Assert.False(string.IsNullOrWhiteSpace(Resources.GetString(key, Russian)), key);
+ }
+ }
+
+ ///
+ /// Every key the settings window markup asks for has to exist in the
+ /// resources: otherwise the user sees the key itself in its place.
+ ///
+ [Fact]
+ public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
+ {
+ HashSet known = [.. NeutralKeys()];
+ List missing = [];
+
+ foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
+ {
+ string key = match.Groups["key"].Value;
+ if (!known.Contains(key))
+ {
+ missing.Add(key);
+ }
+ }
+
+ Assert.Empty(missing);
+ }
+
+ ///
+ /// The version of an update is put into the string by the app, so the place
+ /// for it has to be there in both languages.
+ ///
+ [Fact]
+ public void The_string_about_an_available_update_has_room_for_the_version()
+ {
+ Assert.Contains("{0}", Resources.GetString("UpdateAvailable", English), StringComparison.Ordinal);
+ Assert.Contains("{0}", Resources.GetString("UpdateAvailable", Russian), StringComparison.Ordinal);
+ }
+
+ /// The markup does ask for strings — otherwise the check above means nothing.
+ [Fact]
+ public void The_settings_window_markup_asks_for_resource_strings()
+ {
+ Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
+ }
+
+ public static TheoryData EnumKeys()
+ {
+ var data = new TheoryData();
+
+ foreach (string key in EnumKeysOf())
+ {
+ data.Add(key);
+ }
+
+ foreach (string key in EnumKeysOf())
+ {
+ data.Add(key);
+ }
+
+ foreach (string key in EnumKeysOf())
+ {
+ data.Add(key);
+ }
+
+ foreach (string key in EnumKeysOf())
+ {
+ data.Add(key);
+ }
+
+ return data;
+ }
+
+ // A caption key is built from the type name and the value: PopupPlacementMode_AtCursor
+ private static IEnumerable EnumKeysOf() where TEnum : struct, Enum =>
+ Enum.GetValues().Select(value => $"{typeof(TEnum).Name}_{value}");
+
+ private static IEnumerable NeutralKeys()
+ {
+ ResourceSet set = Resources.GetResourceSet(CultureInfo.InvariantCulture, true, true)!;
+
+ foreach (DictionaryEntry entry in set)
+ {
+ yield return (string)entry.Key;
+ }
+ }
+
+ private static ResourceSet RussianSet() =>
+ Resources.GetResourceSet(Russian, createIfNotExists: true, tryParents: false)!;
+
+ private static string ReadSettingsWindowMarkup()
+ {
+ using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
+ ?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
+
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ [GeneratedRegex(@"Localization\[(?\w+)\]")]
+ private static partial Regex LocalizationBinding();
+}
diff --git a/CursorLang.Tests/Services/CapsLockHotkeyServiceTests.cs b/CursorLang.Tests/Services/CapsLockHotkeyServiceTests.cs
new file mode 100644
index 0000000..3840daf
--- /dev/null
+++ b/CursorLang.Tests/Services/CapsLockHotkeyServiceTests.cs
@@ -0,0 +1,274 @@
+using System.Collections.Concurrent;
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.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(Sta.Run(() => harness.Service.HandleKeyEvent(CapsLock, isKeyDown: true)));
+ Assert.True(Sta.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(Sta.Run(() => harness.Service.HandleKeyEvent(LetterA, isKeyDown: true)));
+ Assert.False(Sta.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();
+
+ Sta.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();
+
+ Sta.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();
+ Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+
+ harness.Release();
+
+ Sta.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++)
+ {
+ Sta.Pause(TimeSpan.FromMilliseconds(10));
+ harness.Press();
+ }
+
+ Sta.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();
+
+ Sta.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();
+ Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+
+ Sta.Run(harness.Service.Stop);
+
+ Sta.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();
+ Sta.Run(harness.Service.Stop);
+ Sta.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();
+ Sta.Run(harness.Service.Stop);
+
+ Sta.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();
+ Sta.Run(harness.Service.Stop);
+
+ harness.Press();
+ harness.Release();
+
+ Sta.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();
+ Sta.WaitFor(() => harness.Events.Contains("hold-start"), "the hold threshold was passed");
+
+ Sta.Run(harness.Service.Dispose);
+ Sta.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();
+ Sta.WaitFor(() => harness.Events.Count == 1, "the first press counted as short");
+
+ harness.Settings.CapsLockHoldMilliseconds = 20;
+ harness.Press();
+
+ Sta.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);
+ Sta.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);
+
+ Sta.Run(() =>
+ {
+ harness.Service.Start();
+ harness.Service.Dispose();
+
+ Assert.False(harness.Service.IsRunning);
+ });
+ }
+
+ ///
+ /// 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 };
+
+ // The service remembers the dispatcher of the thread it was created on
+ CapsLockHotkeyService service = Sta.Run(() => new CapsLockHotkeyService(settings));
+ 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() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: true));
+
+ internal void Release() => Sta.Run(() => Service.HandleKeyEvent(CapsLock, isKeyDown: false));
+
+ public void Dispose() => Sta.Run(Service.Dispose);
+ }
+}
diff --git a/CursorLang.Tests/Services/CapsLockSwitchCoordinatorTests.cs b/CursorLang.Tests/Services/CapsLockSwitchCoordinatorTests.cs
new file mode 100644
index 0000000..1f8bd4a
--- /dev/null
+++ b/CursorLang.Tests/Services/CapsLockSwitchCoordinatorTests.cs
@@ -0,0 +1,192 @@
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// What happens on Caps Lock presses and how the interception follows the setting.
+///
+public sealed class CapsLockSwitchCoordinatorTests
+{
+ [Fact]
+ public void With_the_setting_on_the_interception_starts_at_once()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ using CapsLockSwitchCoordinator coordinator = Create(hotkey, new AppSettings { UseCapsLockHotkey = true });
+
+ coordinator.Start();
+
+ Assert.True(hotkey.IsRunning);
+ Assert.Equal(1, hotkey.StartCalls);
+ }
+
+ [Fact]
+ public void With_the_setting_off_there_is_no_interception()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ using CapsLockSwitchCoordinator coordinator = Create(hotkey, new AppSettings { UseCapsLockHotkey = false });
+
+ coordinator.Start();
+
+ Assert.False(hotkey.IsRunning);
+ Assert.Equal(1, hotkey.StopCalls);
+ }
+
+ [Fact]
+ public void Ticking_the_setting_turns_the_interception_on_live()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var settings = new AppSettings { UseCapsLockHotkey = false };
+ using CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
+ coordinator.Start();
+
+ settings.UseCapsLockHotkey = true;
+
+ Assert.True(hotkey.IsRunning);
+ }
+
+ [Fact]
+ public void Unticking_the_setting_gives_the_key_its_usual_behaviour_back()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var settings = new AppSettings { UseCapsLockHotkey = true };
+ using CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
+ coordinator.Start();
+
+ settings.UseCapsLockHotkey = false;
+
+ Assert.False(hotkey.IsRunning);
+ }
+
+ // The interception only follows its own setting
+ [Fact]
+ public void Other_settings_leave_the_interception_alone()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var settings = new AppSettings { UseCapsLockHotkey = true };
+ using CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
+ coordinator.Start();
+
+ int startsBefore = hotkey.StartCalls;
+ int stopsBefore = hotkey.StopCalls;
+
+ settings.FontSize = 44;
+ settings.CapsLockHoldMilliseconds = 700;
+
+ Assert.Equal(startsBefore, hotkey.StartCalls);
+ Assert.Equal(stopsBefore, hotkey.StopCalls);
+ }
+
+ [Fact]
+ public void A_short_press_switches_the_layout()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new CapsLockSwitchCoordinator(
+ hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
+ coordinator.Start();
+
+ hotkey.RaiseTapped();
+
+ Assert.Equal(1, layouts.SwitchCalls);
+ Assert.Empty(popup.Shown);
+ Assert.Empty(popup.ShownUntilHidden);
+ }
+
+ // The layout stays put, but staying silent is not an option either: without
+ // a tooltip a long press looks like a key that did not work
+ [Fact]
+ public void A_long_press_shows_the_current_layout()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var layouts = new FakeKeyboardLayoutService
+ {
+ CurrentLayout = KeyboardLayout.FromLocaleId(0x0419),
+ };
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new CapsLockSwitchCoordinator(
+ hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
+ coordinator.Start();
+
+ hotkey.RaiseHoldStarted();
+
+ Assert.Equal([layouts.CurrentLayout], popup.ShownUntilHidden);
+ Assert.Equal(0, layouts.SwitchCalls);
+ }
+
+ [Fact]
+ public void When_the_hold_ends_the_tooltip_goes_away()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new CapsLockSwitchCoordinator(
+ hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
+ coordinator.Start();
+
+ hotkey.RaiseHoldStarted();
+ hotkey.RaiseHoldEnded();
+
+ Assert.Equal(1, popup.HideCalls);
+ Assert.Equal(0, layouts.SwitchCalls);
+ }
+
+ [Fact]
+ public void Before_the_start_presses_do_nothing()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new CapsLockSwitchCoordinator(
+ hotkey, layouts, popup, new AppSettings { UseCapsLockHotkey = true });
+
+ hotkey.RaiseTapped();
+ hotkey.RaiseHoldStarted();
+
+ Assert.Equal(0, layouts.SwitchCalls);
+ Assert.Empty(popup.ShownUntilHidden);
+ }
+
+ [Fact]
+ public void Closing_removes_the_interception_and_unsubscribes_from_presses()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var layouts = new FakeKeyboardLayoutService();
+ var settings = new AppSettings { UseCapsLockHotkey = true };
+
+ var coordinator = new CapsLockSwitchCoordinator(
+ hotkey, layouts, new FakeLayoutPopupService(), settings);
+ coordinator.Start();
+ coordinator.Dispose();
+
+ Assert.False(hotkey.IsRunning);
+ Assert.False(hotkey.HasSubscribers);
+
+ hotkey.RaiseTapped();
+ Assert.Equal(0, layouts.SwitchCalls);
+ }
+
+ [Fact]
+ public void After_closing_the_setting_no_longer_turns_the_interception_on()
+ {
+ var hotkey = new FakeCapsLockHotkeyService();
+ var settings = new AppSettings { UseCapsLockHotkey = false };
+
+ CapsLockSwitchCoordinator coordinator = Create(hotkey, settings);
+ coordinator.Start();
+ coordinator.Dispose();
+
+ settings.UseCapsLockHotkey = true;
+
+ Assert.False(hotkey.IsRunning);
+ }
+
+ private static CapsLockSwitchCoordinator Create(FakeCapsLockHotkeyService hotkey, AppSettings settings) =>
+ new(hotkey, new FakeKeyboardLayoutService(), new FakeLayoutPopupService(), settings);
+}
diff --git a/CursorLang.Tests/Services/KeyboardLayoutServiceTests.cs b/CursorLang.Tests/Services/KeyboardLayoutServiceTests.cs
new file mode 100644
index 0000000..048ba7e
--- /dev/null
+++ b/CursorLang.Tests/Services/KeyboardLayoutServiceTests.cs
@@ -0,0 +1,288 @@
+using System.Collections.Concurrent;
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// Watching the layout of the foreground window. The test supplies what the
+/// system reports: what is under test is the decision about what counts as
+/// a layout change.
+///
+public sealed class KeyboardLayoutServiceTests
+{
+ private static readonly IntPtr FirstWindow = new(1000);
+ private static readonly IntPtr SecondWindow = new(2000);
+
+ private const int English = 0x0409;
+ private const int Russian = 0x0419;
+
+ [Fact]
+ public void The_current_layout_is_taken_from_the_foreground_window()
+ {
+ using var world = new World { LocaleId = Russian };
+ KeyboardLayoutService service = world.CreateService();
+
+ Assert.Equal("RU", service.Current.ShortName);
+
+ world.LocaleId = English;
+ Assert.Equal("EN", service.Current.ShortName);
+ }
+
+ [Fact]
+ public void Switching_asks_the_system_to_change_the_layout()
+ {
+ using var world = new World();
+ KeyboardLayoutService service = world.CreateService();
+
+ service.SwitchToNext();
+ service.SwitchToNext();
+
+ Assert.Equal(2, world.SwitchRequests);
+ }
+
+ [Fact]
+ public void A_layout_change_in_the_same_window_counts_as_the_users_doing()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService();
+
+ Sta.Run(service.Start);
+ world.LocaleId = Russian;
+ Sta.Run(service.Poll);
+
+ LayoutChangedEventArgs change = Assert.Single(world.Changes);
+ Assert.Equal(LayoutChangeReason.UserSwitched, change.Reason);
+ Assert.Equal("RU", change.Layout.ShortName);
+ }
+
+ [Fact]
+ public void Moving_to_another_application_differs_from_switching()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService();
+
+ Sta.Run(service.Start);
+
+ world.ForegroundWindow = SecondWindow;
+ world.LocaleId = Russian;
+ Sta.Run(service.Poll);
+
+ LayoutChangedEventArgs change = Assert.Single(world.Changes);
+ Assert.Equal(LayoutChangeReason.ApplicationSwitched, change.Reason);
+ }
+
+ // Moving to an application with the same layout changes nothing
+ [Fact]
+ public void Moving_without_a_layout_change_yields_no_events()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService();
+
+ Sta.Run(service.Start);
+
+ world.ForegroundWindow = SecondWindow;
+ Sta.Run(service.Poll);
+
+ Assert.Empty(world.Changes);
+ }
+
+ [Fact]
+ public void An_unchanged_layout_yields_no_events()
+ {
+ using var world = new World { LocaleId = Russian };
+ KeyboardLayoutService service = world.CreateService();
+
+ Sta.Run(service.Start);
+
+ for (int i = 0; i < 5; i++)
+ {
+ Sta.Run(service.Poll);
+ }
+
+ Assert.Empty(world.Changes);
+ }
+
+ // There is no foreground window — during a desktop switch, for one
+ [Fact]
+ public void Without_a_foreground_window_the_poll_is_skipped()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService();
+
+ Sta.Run(service.Start);
+
+ world.ForegroundWindow = IntPtr.Zero;
+ world.LocaleId = Russian;
+ Sta.Run(service.Poll);
+
+ Assert.Empty(world.Changes);
+
+ // And the layout was not remembered: the change is noticed once a window is back
+ world.ForegroundWindow = FirstWindow;
+ Sta.Run(service.Poll);
+
+ Assert.Single(world.Changes);
+ }
+
+ [Fact]
+ public void One_change_yields_exactly_one_event()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService();
+
+ Sta.Run(service.Start);
+
+ world.LocaleId = Russian;
+ Sta.Run(service.Poll);
+ Sta.Run(service.Poll);
+ Sta.Run(service.Poll);
+
+ Assert.Single(world.Changes);
+ }
+
+ [Fact]
+ public void A_layout_change_before_the_watch_starts_goes_unnoticed()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService();
+
+ world.LocaleId = Russian;
+ Sta.Run(service.Start);
+ Sta.Run(service.Poll);
+
+ // Start remembered the layout that was in place at that moment
+ Assert.Empty(world.Changes);
+ }
+
+ [Fact]
+ public void The_watch_runs_on_a_timer()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
+
+ Sta.Run(service.Start);
+ world.LocaleId = Russian;
+
+ Sta.WaitFor(() => !world.Changes.IsEmpty, "the timer noticed the layout change");
+ }
+
+ [Fact]
+ public void Stopping_ends_the_polling()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
+
+ Sta.Run(service.Start);
+ Sta.Run(service.Stop);
+
+ world.LocaleId = Russian;
+ Sta.Pause(TimeSpan.FromMilliseconds(120));
+
+ Assert.Empty(world.Changes);
+ }
+
+ [Fact]
+ public void Closing_ends_the_polling()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
+
+ Sta.Run(service.Start);
+ Sta.Run(service.Dispose);
+
+ world.LocaleId = Russian;
+ Sta.Pause(TimeSpan.FromMilliseconds(120));
+
+ Assert.Empty(world.Changes);
+ }
+
+ [Fact]
+ public void The_watch_can_be_resumed()
+ {
+ using var world = new World { LocaleId = English };
+ KeyboardLayoutService service = world.CreateService(TimeSpan.FromMilliseconds(15));
+
+ Sta.Run(service.Start);
+ Sta.Run(service.Stop);
+ Sta.Run(service.Start);
+
+ world.LocaleId = Russian;
+
+ Sta.WaitFor(() => !world.Changes.IsEmpty, "the watch resumed");
+ }
+
+ // The ordinary service asks Windows itself about the layout
+ [Fact]
+ public void The_service_can_work_with_the_real_system()
+ {
+ KeyboardLayoutService service = Sta.Run(() =>
+ new KeyboardLayoutService(new KeyboardLayoutOptions { PollInterval = TimeSpan.FromHours(1) }));
+
+ try
+ {
+ Sta.Run(service.Start);
+ Sta.Run(service.Poll);
+
+ Assert.InRange(service.Current.LocaleId, 1, 0xFFFF);
+
+ Sta.Run(service.Stop);
+ }
+ finally
+ {
+ Sta.Run(service.Dispose);
+ }
+ }
+
+ [Fact]
+ public void By_default_the_poll_runs_more_than_six_times_a_second()
+ {
+ // Any rarer and the tooltip would visibly lag behind the keystroke
+ Assert.True(new KeyboardLayoutOptions().PollInterval <= TimeSpan.FromMilliseconds(150));
+ }
+
+ ///
+ /// The state of the system as the service sees it, and everything the
+ /// service reported about it.
+ ///
+ private sealed class World : IDisposable
+ {
+ private KeyboardLayoutService? _service;
+
+ internal IntPtr ForegroundWindow { get; set; } = FirstWindow;
+
+ internal int LocaleId { get; set; } = English;
+
+ internal int SwitchRequests { get; private set; }
+
+ internal ConcurrentQueue Changes { get; } = new();
+
+ internal KeyboardLayoutService CreateService(TimeSpan? pollInterval = null)
+ {
+ // An hour between ticks means the poll only runs when the test asks for it
+ var options = new KeyboardLayoutOptions
+ {
+ PollInterval = pollInterval ?? TimeSpan.FromHours(1),
+ };
+
+ _service = Sta.Run(() => new KeyboardLayoutService(
+ options,
+ () => ForegroundWindow,
+ () => LocaleId,
+ () => SwitchRequests++));
+
+ _service.LayoutChanged += (_, e) => Changes.Enqueue(e);
+
+ return _service;
+ }
+
+ public void Dispose()
+ {
+ if (_service is not null)
+ {
+ Sta.Run(_service.Dispose);
+ }
+ }
+ }
+}
diff --git a/CursorLang.Tests/Services/LayoutNotificationCoordinatorTests.cs b/CursorLang.Tests/Services/LayoutNotificationCoordinatorTests.cs
new file mode 100644
index 0000000..c13e886
--- /dev/null
+++ b/CursorLang.Tests/Services/LayoutNotificationCoordinatorTests.cs
@@ -0,0 +1,116 @@
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// The link between watching the layout and showing the tooltip.
+///
+public sealed class LayoutNotificationCoordinatorTests
+{
+ private static readonly KeyboardLayout Russian = KeyboardLayout.FromLocaleId(0x0419);
+
+ [Fact]
+ public void Starting_turns_on_the_layout_watch()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+ coordinator.Start();
+
+ Assert.Equal(1, layouts.StartCalls);
+ }
+
+ [Fact]
+ public void A_layout_switched_by_the_user_shows_the_tooltip()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+ coordinator.Start();
+
+ layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
+
+ Assert.Equal([Russian], popup.Shown);
+ }
+
+ // Moving to another application changes the layout with no user involved,
+ // and the tooltip would be intrusive
+ [Fact]
+ public void Switching_applications_shows_no_tooltip()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+ coordinator.Start();
+
+ layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.ApplicationSwitched);
+
+ Assert.Empty(popup.Shown);
+ }
+
+ [Fact]
+ public void Every_switch_shows_its_own_layout()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+ coordinator.Start();
+
+ KeyboardLayout english = KeyboardLayout.FromLocaleId(0x0409);
+ layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
+ layouts.RaiseLayoutChanged(english, LayoutChangeReason.UserSwitched);
+
+ Assert.Equal([Russian, english], popup.Shown);
+ }
+
+ [Fact]
+ public void Before_the_start_no_tooltip_is_shown()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+
+ layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
+
+ Assert.Empty(popup.Shown);
+ }
+
+ [Fact]
+ public void Closing_stops_the_watch_and_unsubscribes_from_the_event()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+ coordinator.Start();
+ coordinator.Dispose();
+
+ Assert.Equal(1, layouts.StopCalls);
+ Assert.False(layouts.HasSubscribers);
+
+ layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
+ Assert.Empty(popup.Shown);
+ }
+
+ [Fact]
+ public void The_show_until_hidden_is_not_called_from_here()
+ {
+ var layouts = new FakeKeyboardLayoutService();
+ var popup = new FakeLayoutPopupService();
+
+ using var coordinator = new LayoutNotificationCoordinator(layouts, popup);
+ coordinator.Start();
+
+ layouts.RaiseLayoutChanged(Russian, LayoutChangeReason.UserSwitched);
+
+ Assert.Empty(popup.ShownUntilHidden);
+ Assert.Equal(0, popup.HideCalls);
+ }
+}
diff --git a/CursorLang.Tests/Services/LayoutPopupServiceTests.cs b/CursorLang.Tests/Services/LayoutPopupServiceTests.cs
new file mode 100644
index 0000000..73c4970
--- /dev/null
+++ b/CursorLang.Tests/Services/LayoutPopupServiceTests.cs
@@ -0,0 +1,212 @@
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+using CursorLang.ViewModels;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// The lifetime of the tooltip. Its timer lives on the interface thread,
+/// so everything happens there as well.
+///
+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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+ });
+
+ Assert.Equal("RU", viewModel.ShortName);
+ 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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+
+ Assert.Equal(0, window.HideCalls);
+ Sta.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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+
+ settings.DurationMilliseconds = 30;
+ service.Show(English);
+
+ Sta.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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+
+ for (int i = 0; i < 5; i++)
+ {
+ service.Show(i % 2 == 0 ? Russian : English);
+ Sta.Pause(TimeSpan.FromMilliseconds(20));
+ Assert.Equal(0, window.HideCalls);
+ }
+
+ Sta.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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.ShowUntilHidden(Russian);
+
+ Sta.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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+ service.ShowUntilHidden(English);
+
+ Sta.Pause(TimeSpan.FromMilliseconds(100));
+
+ Assert.Equal(0, window.HideCalls);
+ Assert.Equal("EN", viewModel.ShortName);
+ });
+ }
+
+ [Fact]
+ public void Hiding_cancels_a_running_countdown()
+ {
+ var window = new FakeLayoutPopupWindow();
+ var settings = new AppSettings { DurationMilliseconds = 30 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+ service.Hide();
+
+ Sta.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();
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+ service.Dispose();
+
+ Sta.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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+ service.Dispose();
+
+ Sta.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 };
+ var viewModel = new LayoutPopupViewModel(settings);
+
+ Sta.Run(() =>
+ {
+ using var service = new LayoutPopupService(window, viewModel, settings);
+ service.Show(Russian);
+
+ Sta.WaitFor(() => window.HideCalls == 1, "the tooltip hid itself");
+
+ Assert.Equal(["show", "hide"], window.Calls);
+ });
+ }
+}
diff --git a/CursorLang.Tests/Services/LocalizationServiceTests.cs b/CursorLang.Tests/Services/LocalizationServiceTests.cs
new file mode 100644
index 0000000..dbac3e3
--- /dev/null
+++ b/CursorLang.Tests/Services/LocalizationServiceTests.cs
@@ -0,0 +1,149 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Windows.Data;
+using CursorLang.Services;
+
+namespace CursorLang.Tests.Services;
+
+public sealed class LocalizationServiceTests
+{
+ [Fact]
+ public void The_interface_starts_out_in_English()
+ {
+ Assert.Equal("en", new LocalizationService().CurrentLanguage);
+ }
+
+ [Fact]
+ public void A_string_comes_from_the_resources_of_the_chosen_language()
+ {
+ var localization = new LocalizationService();
+
+ string english = localization["SettingsTitle"];
+ localization.CurrentLanguage = "ru";
+ string russian = localization["SettingsTitle"];
+
+ Assert.False(string.IsNullOrWhiteSpace(english));
+ Assert.False(string.IsNullOrWhiteSpace(russian));
+ Assert.NotEqual(english, russian);
+ }
+
+ // A missing key shows in the interface but does not bring the app down
+ [Fact]
+ public void An_unknown_key_comes_back_as_is()
+ {
+ var localization = new LocalizationService();
+
+ Assert.Equal("NoSuchKey", localization["NoSuchKey"]);
+ }
+
+ [Fact]
+ public void A_language_change_is_announced_to_subscribers()
+ {
+ var localization = new LocalizationService();
+ List changed = [];
+ localization.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ localization.CurrentLanguage = "ru";
+
+ Assert.Contains(nameof(LocalizationService.CurrentLanguage), changed);
+
+ // The indexer is announced separately: that is how the whole text refreshes
+ Assert.Contains(Binding.IndexerName, changed);
+ }
+
+ [Fact]
+ public void The_same_language_is_not_announced_again()
+ {
+ var localization = new LocalizationService { CurrentLanguage = "ru" };
+ List changed = [];
+ localization.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ localization.CurrentLanguage = "ru";
+
+ Assert.Empty(changed);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void An_empty_language_changes_nothing(string? value)
+ {
+ var localization = new LocalizationService();
+ List changed = [];
+ localization.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ localization.CurrentLanguage = value!;
+
+ Assert.Equal("en", localization.CurrentLanguage);
+ Assert.Empty(changed);
+ }
+
+ [Fact]
+ public void A_language_with_a_country_falls_back_to_the_language_code()
+ {
+ var localization = new LocalizationService { CurrentLanguage = "ru-RU" };
+
+ Assert.Equal("ru", localization.CurrentLanguage);
+ }
+
+ // Changing the app language has to change the language of the thread as
+ // well: other texts, down to system messages, depend on it
+ [Fact]
+ public void A_language_change_changes_the_language_of_the_thread()
+ {
+ CultureInfo previous = CultureInfo.CurrentUICulture;
+ try
+ {
+ var localization = new LocalizationService { CurrentLanguage = "ru" };
+
+ Assert.Equal("ru", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName);
+ Assert.Equal("ru", localization.CurrentLanguage);
+ }
+ finally
+ {
+ CultureInfo.CurrentUICulture = previous;
+ }
+ }
+
+ [Fact]
+ public void English_and_Russian_are_offered_for_choosing()
+ {
+ IReadOnlyList languages = new LocalizationService().AvailableLanguages;
+
+ Assert.Equal(2, languages.Count);
+ Assert.Contains(languages, language => language.Code == "en");
+ Assert.Contains(languages, language => language.Code == "ru");
+ }
+
+ // A language is named in itself: that way it is recognised even by someone
+ // who does not know the current interface language
+ [Fact]
+ public void The_languages_are_named_in_themselves()
+ {
+ IReadOnlyList languages = new LocalizationService().AvailableLanguages;
+
+ Assert.Equal("English", languages.Single(language => language.Code == "en").DisplayName);
+ Assert.Equal("Русский", languages.Single(language => language.Code == "ru").DisplayName);
+ }
+
+ // Accessibility tools take the name of a list item from ToString
+ [Fact]
+ public void A_language_presents_itself_by_its_name()
+ {
+ Assert.Equal("Русский", new LanguageOption("ru", "Русский").ToString());
+ }
+
+ [Fact]
+ public void Languages_with_the_same_code_and_name_are_equal()
+ {
+ Assert.Equal(new LanguageOption("ru", "Русский"), new LanguageOption("ru", "Русский"));
+ Assert.NotEqual(new LanguageOption("ru", "Русский"), new LanguageOption("en", "English"));
+ }
+
+ [Fact]
+ public void The_service_reports_changes_as_INotifyPropertyChanged()
+ {
+ Assert.IsAssignableFrom(new LocalizationService());
+ }
+}
diff --git a/CursorLang.Tests/Services/MainWindowPlacementTests.cs b/CursorLang.Tests/Services/MainWindowPlacementTests.cs
new file mode 100644
index 0000000..e3e0f1c
--- /dev/null
+++ b/CursorLang.Tests/Services/MainWindowPlacementTests.cs
@@ -0,0 +1,287 @@
+using System.Windows;
+using System.Windows.Interop;
+using CursorLang.Interop;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// Placing the settings window: the centre maths and bringing the window back
+/// into the work area.
+///
+public sealed class MainWindowPlacementTests
+{
+ private static readonly PopupWindowNative.Rect Work = new()
+ {
+ Left = 0,
+ Top = 0,
+ Right = 1000,
+ Bottom = 800,
+ };
+
+ private static readonly PopupWindowNative.Rect Bounds = new()
+ {
+ Left = 0,
+ Top = 0,
+ Right = 400,
+ Bottom = 300,
+ };
+
+ [Fact]
+ public void The_centre_follows_the_window_size_and_the_work_area()
+ {
+ PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, Work);
+
+ Assert.Equal((1000 - 400) / 2, point.X);
+ Assert.Equal((800 - 300) / 2, point.Y);
+ }
+
+ // The work area of a second monitor does not start at zero
+ [Fact]
+ public void The_centre_of_a_neighbouring_monitor_is_measured_from_its_left_edge()
+ {
+ var work = new PopupWindowNative.Rect { Left = 1920, Top = 100, Right = 3520, Bottom = 1000 };
+
+ PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, work);
+
+ Assert.Equal(1920 + ((1600 - 400) / 2), point.X);
+ Assert.Equal(100 + ((900 - 300) / 2), point.Y);
+ }
+
+ [Fact]
+ public void A_window_inside_the_work_area_stays_where_it_is()
+ {
+ var position = new PopupWindowNative.Point { X = 120, Y = 90 };
+
+ PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
+
+ Assert.Equal(120, clamped.X);
+ Assert.Equal(90, clamped.Y);
+ }
+
+ [Fact]
+ public void A_window_past_the_right_edge_is_pulled_back_in()
+ {
+ var position = new PopupWindowNative.Point { X = 900, Y = 700 };
+
+ PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
+
+ Assert.Equal(1000 - 400, clamped.X);
+ Assert.Equal(800 - 300, clamped.Y);
+ }
+
+ [Fact]
+ public void A_window_past_the_left_and_top_edges_is_pulled_back_in()
+ {
+ var position = new PopupWindowNative.Point { X = -500, Y = -400 };
+
+ PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
+
+ Assert.Equal(Work.Left, clamped.X);
+ Assert.Equal(Work.Top, clamped.Y);
+ }
+
+ // The window height matches its content and on a short monitor exceeds the
+ // work area. The title bar matters more than the bottom of the window
+ [Fact]
+ public void A_window_taller_than_the_work_area_is_pinned_to_its_top()
+ {
+ var tall = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 400, Bottom = 900 };
+ var position = new PopupWindowNative.Point { X = 0, Y = 300 };
+
+ PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, tall, Work);
+
+ Assert.Equal(Work.Top, clamped.Y);
+ }
+
+ [Fact]
+ public void A_window_wider_than_the_work_area_is_pinned_to_its_left_edge()
+ {
+ var wide = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1200, Bottom = 300 };
+ var position = new PopupWindowNative.Point { X = 400, Y = 0 };
+
+ PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, wide, Work);
+
+ Assert.Equal(Work.Left, clamped.X);
+ }
+
+ [Theory]
+ [InlineData(0, 0, 0, 0, true)]
+ [InlineData(0, 0, 100, 0, true)]
+ [InlineData(0, 0, 0, 100, true)]
+ [InlineData(100, 100, 100, 200, true)]
+ [InlineData(0, 0, 1, 1, false)]
+ [InlineData(-100, -100, 100, 100, false)]
+ public void An_area_without_width_or_height_counts_as_empty(
+ int left, int top, int right, int bottom, bool expected)
+ {
+ var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
+
+ Assert.Equal(expected, MainWindowPlacement.IsEmpty(rect));
+ }
+
+ [Fact]
+ public void The_first_time_in_a_session_the_window_lands_centred_on_the_active_monitor()
+ {
+ Sta.Run(() =>
+ {
+ var placement = new MainWindowPlacement();
+ using var window = new TestWindow();
+
+ (PopupWindowNative.Rect before, _) = PopupWindowNative.GetActiveMonitorWorkArea();
+ placement.Apply(window);
+ (PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
+
+ if (!before.Equals(work))
+ {
+ // The user moved to another monitor right during the check
+ Assert.Skip("The active monitor changed while the check was running");
+ }
+
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+
+ // Pixel precision: the window goes exactly where it was computed to go
+ PopupWindowNative.Point expected = MainWindowPlacement.Clamp(
+ MainWindowPlacement.Center(bounds, work), bounds, work);
+
+ Assert.Equal(expected.X, bounds.Left);
+ Assert.Equal(expected.Y, bounds.Top);
+ });
+ }
+
+ [Fact]
+ public void The_window_returns_where_the_user_moved_it()
+ {
+ Sta.Run(() =>
+ {
+ var placement = new MainWindowPlacement();
+ using var window = new TestWindow();
+
+ placement.Attach(window);
+ placement.Apply(window);
+
+ // Move the window the way the user does it with the mouse
+ PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ PopupWindowNative.MoveTo(window.Handle, centered.Left + 40, centered.Top + 30);
+ window.RaiseLocationChanged();
+
+ // Showing the window again — it has to stay where it was left
+ placement.Apply(window);
+
+ PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ Assert.Equal(centered.Left + 40, after.Left);
+ Assert.Equal(centered.Top + 30, after.Top);
+ });
+ }
+
+ // While we move the window ourselves its position must not drift from repeats
+ [Fact]
+ public void Placing_again_does_not_move_the_window()
+ {
+ Sta.Run(() =>
+ {
+ var placement = new MainWindowPlacement();
+ using var window = new TestWindow();
+
+ placement.Attach(window);
+ placement.Apply(window);
+
+ PopupWindowNative.Rect first = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+
+ placement.Apply(window);
+ placement.Apply(window);
+
+ PopupWindowNative.Rect third = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ Assert.Equal(first.Left, third.Left);
+ Assert.Equal(first.Top, third.Top);
+ });
+ }
+
+ [Fact]
+ public void A_minimised_window_is_not_placed()
+ {
+ Sta.Run(() =>
+ {
+ var placement = new MainWindowPlacement();
+ using var window = new TestWindow();
+
+ PopupWindowNative.MoveTo(window.Handle, 7, 9);
+ window.WindowState = WindowState.Minimized;
+
+ placement.Apply(window);
+
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ Assert.Equal(7, bounds.Left);
+ Assert.Equal(9, bounds.Top);
+ });
+ }
+
+ [Fact]
+ public void A_window_without_a_handle_is_not_placed()
+ {
+ Sta.Run(() =>
+ {
+ var placement = new MainWindowPlacement();
+ var window = new Window { Width = 200, Height = 150 };
+
+ // There must be no exception: the window does not exist yet,
+ // so there is nothing to place
+ placement.Apply(window);
+
+ Assert.Equal(IntPtr.Zero, new WindowInteropHelper(window).Handle);
+ });
+ }
+
+ // The place of the window lives in memory only: the set of monitors may be
+ // different by the next run
+ [Fact]
+ public void Every_placement_starts_its_session_afresh()
+ {
+ Sta.Run(() =>
+ {
+ using var window = new TestWindow();
+
+ var first = new MainWindowPlacement();
+ first.Attach(window);
+ first.Apply(window);
+
+ PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+
+ PopupWindowNative.MoveTo(window.Handle, centered.Left + 60, centered.Top + 60);
+ window.RaiseLocationChanged();
+
+ // A new placement knows nothing of the earlier move and centres the window again
+ var second = new MainWindowPlacement();
+ second.Apply(window);
+
+ PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
+ Assert.Equal(centered.Left, bounds.Left);
+ Assert.Equal(centered.Top, bounds.Top);
+ });
+ }
+
+ ///
+ /// A window with a ready handle that never appears on screen: placement
+ /// works with the system bounds, and a created window is enough for those.
+ ///
+ private sealed class TestWindow : Window, IDisposable
+ {
+ internal TestWindow()
+ {
+ Width = 400;
+ Height = 300;
+ ShowInTaskbar = false;
+ WindowStartupLocation = WindowStartupLocation.Manual;
+
+ Handle = new WindowInteropHelper(this).EnsureHandle();
+ }
+
+ internal IntPtr Handle { get; }
+
+ /// Reports a move the way WPF does after the user acts.
+ internal void RaiseLocationChanged() => OnLocationChanged(EventArgs.Empty);
+
+ public void Dispose() => Close();
+ }
+}
diff --git a/CursorLang.Tests/Services/PopupLayoutTests.cs b/CursorLang.Tests/Services/PopupLayoutTests.cs
new file mode 100644
index 0000000..ed5b0cb
--- /dev/null
+++ b/CursorLang.Tests/Services/PopupLayoutTests.cs
@@ -0,0 +1,229 @@
+using CursorLang.Interop;
+using CursorLang.Models;
+using CursorLang.Services;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// The placement maths for the tooltip. This is the easiest place to get a sign
+/// or half a size wrong, and on screen such a mistake is only visible by eye.
+///
+public sealed class PopupLayoutTests
+{
+ // The anchor: 100..140 horizontally, 200..220 vertically
+ private static readonly PopupWindowNative.Rect Anchor = new()
+ {
+ Left = 100,
+ Top = 200,
+ Right = 140,
+ Bottom = 220,
+ };
+
+ private const int Offset = 10;
+ private const int Width = 30;
+ private const int Height = 16;
+
+ [Fact]
+ public void Bottom_right_offsets_the_tooltip_from_the_bottom_right_corner()
+ {
+ PopupWindowNative.Point point = Near(AnchorSide.BottomRight);
+
+ Assert.Equal(140 + 10, point.X);
+ Assert.Equal(220 + 10, point.Y);
+ }
+
+ [Fact]
+ public void Bottom_left_fits_the_tooltip_to_the_left_of_the_anchor()
+ {
+ PopupWindowNative.Point point = Near(AnchorSide.BottomLeft);
+
+ Assert.Equal(100 - 10 - Width, point.X);
+ Assert.Equal(220 + 10, point.Y);
+ }
+
+ [Fact]
+ public void Top_right_fits_the_tooltip_above_the_anchor()
+ {
+ PopupWindowNative.Point point = Near(AnchorSide.TopRight);
+
+ Assert.Equal(140 + 10, point.X);
+ Assert.Equal(200 - 10 - Height, point.Y);
+ }
+
+ [Fact]
+ public void Top_left_fits_the_tooltip_both_left_of_and_above_the_anchor()
+ {
+ PopupWindowNative.Point point = Near(AnchorSide.TopLeft);
+
+ Assert.Equal(100 - 10 - Width, point.X);
+ Assert.Equal(200 - 10 - Height, point.Y);
+ }
+
+ // At the sides the tooltip lines up with the middle of the anchor
+ [Fact]
+ public void On_the_right_the_tooltip_lines_up_with_the_anchor()
+ {
+ PopupWindowNative.Point point = Near(AnchorSide.Right);
+
+ Assert.Equal(140 + 10, point.X);
+ Assert.Equal(200 + ((20 - Height) / 2), point.Y);
+ }
+
+ [Fact]
+ public void On_the_left_the_tooltip_lines_up_with_the_anchor()
+ {
+ PopupWindowNative.Point point = Near(AnchorSide.Left);
+
+ Assert.Equal(100 - 10 - Width, point.X);
+ Assert.Equal(200 + ((20 - Height) / 2), point.Y);
+ }
+
+ // A tooltip taller than the input field: the middle is measured from the
+ // anchor, not from zero
+ [Fact]
+ public void At_the_side_a_tooltip_taller_than_the_anchor_rises_above_it()
+ {
+ PopupWindowNative.Point point =
+ PopupLayout.NearAnchor(Anchor, AnchorSide.Right, Offset, Width, height: 40);
+
+ Assert.Equal(200 + ((20 - 40) / 2), point.Y);
+ Assert.True(point.Y < Anchor.Top);
+ }
+
+ [Fact]
+ public void The_cursor_anchors_as_a_rectangle_of_zero_size()
+ {
+ PopupWindowNative.Rect anchor = PopupLayout.AsAnchor(new PopupWindowNative.Point { X = 50, Y = 60 });
+
+ Assert.Equal(50, anchor.Left);
+ Assert.Equal(50, anchor.Right);
+ Assert.Equal(60, anchor.Top);
+ Assert.Equal(60, anchor.Bottom);
+ }
+
+ [Fact]
+ public void At_the_cursor_both_sides_are_measured_from_the_same_point()
+ {
+ PopupWindowNative.Rect cursor = PopupLayout.AsAnchor(new PopupWindowNative.Point { X = 50, Y = 60 });
+
+ PopupWindowNative.Point bottomRight =
+ PopupLayout.NearAnchor(cursor, AnchorSide.BottomRight, Offset, Width, Height);
+ PopupWindowNative.Point topLeft =
+ PopupLayout.NearAnchor(cursor, AnchorSide.TopLeft, Offset, Width, Height);
+
+ Assert.Equal(60, bottomRight.X);
+ Assert.Equal(70, bottomRight.Y);
+ Assert.Equal(50 - 10 - Width, topLeft.X);
+ Assert.Equal(60 - 10 - Height, topLeft.Y);
+ }
+
+ // A monitor to the left of the primary one gives negative coordinates — that is normal
+ [Fact]
+ public void Negative_coordinates_of_a_neighbouring_monitor_are_allowed()
+ {
+ var anchor = new PopupWindowNative.Rect { Left = -800, Top = -200, Right = -800, Bottom = -200 };
+
+ PopupWindowNative.Point point =
+ PopupLayout.NearAnchor(anchor, AnchorSide.BottomRight, Offset, Width, Height);
+
+ Assert.Equal(-790, point.X);
+ Assert.Equal(-190, point.Y);
+ }
+
+ [Fact]
+ public void A_zero_offset_puts_the_tooltip_flush_against_the_anchor()
+ {
+ PopupWindowNative.Point point =
+ PopupLayout.NearAnchor(Anchor, AnchorSide.BottomRight, offset: 0, Width, Height);
+
+ Assert.Equal(Anchor.Right, point.X);
+ Assert.Equal(Anchor.Bottom, point.Y);
+ }
+
+ [Theory]
+ [InlineData(AnchorSide.TopLeft)]
+ [InlineData(AnchorSide.TopRight)]
+ [InlineData(AnchorSide.Left)]
+ [InlineData(AnchorSide.Right)]
+ [InlineData(AnchorSide.BottomLeft)]
+ [InlineData(AnchorSide.BottomRight)]
+ public void No_side_is_left_behind(AnchorSide side)
+ {
+ // The sides are handled by a switch expression with a fallback branch:
+ // each of them has to get its own place, not the shared "bottom right"
+ PopupWindowNative.Point point = Near(side);
+ PopupWindowNative.Point bottomRight = Near(AnchorSide.BottomRight);
+
+ if (side != AnchorSide.BottomRight)
+ {
+ Assert.True(point.X != bottomRight.X || point.Y != bottomRight.Y);
+ }
+ }
+
+ public static TheoryData ScreenCases() => new()
+ {
+ // A work area of 0..1000 horizontally and 0..800 vertically, margin 20
+ { ScreenPosition.TopLeft, 20, 20 },
+ { ScreenPosition.Top, (1000 - Width) / 2, 20 },
+ { ScreenPosition.TopRight, 1000 - 20 - Width, 20 },
+ { ScreenPosition.Center, (1000 - Width) / 2, (800 - Height) / 2 },
+ { ScreenPosition.BottomLeft, 20, 800 - 20 - Height },
+ { ScreenPosition.Bottom, (1000 - Width) / 2, 800 - 20 - Height },
+ { ScreenPosition.BottomRight, 1000 - 20 - Width, 800 - 20 - Height },
+ };
+
+ [Theory]
+ [MemberData(nameof(ScreenCases))]
+ public void The_place_on_the_monitor_is_measured_from_the_work_area(
+ ScreenPosition position, int expectedX, int expectedY)
+ {
+ var work = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1000, Bottom = 800 };
+
+ PopupWindowNative.Point point = PopupLayout.OnScreen(work, position, margin: 20, Width, Height);
+
+ Assert.Equal(expectedX, point.X);
+ Assert.Equal(expectedY, point.Y);
+ }
+
+ // The work area of a second monitor does not start at zero, and the taskbar
+ // takes its bottom away — the place is measured from those bounds
+ [Fact]
+ public void The_place_on_a_neighbouring_monitor_is_measured_from_its_own_bounds()
+ {
+ var work = new PopupWindowNative.Rect { Left = 1920, Top = 0, Right = 3520, Bottom = 860 };
+
+ PopupWindowNative.Point point =
+ PopupLayout.OnScreen(work, ScreenPosition.BottomRight, margin: 20, Width, Height);
+
+ Assert.Equal(3520 - 20 - Width, point.X);
+ Assert.Equal(860 - 20 - Height, point.Y);
+ }
+
+ [Fact]
+ public void In_the_centre_of_the_monitor_the_margin_is_ignored()
+ {
+ var work = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1000, Bottom = 800 };
+
+ PopupWindowNative.Point withMargin = PopupLayout.OnScreen(work, ScreenPosition.Center, 20, Width, Height);
+ PopupWindowNative.Point withoutMargin = PopupLayout.OnScreen(work, ScreenPosition.Center, 0, Width, Height);
+
+ Assert.Equal(withoutMargin.X, withMargin.X);
+ Assert.Equal(withoutMargin.Y, withMargin.Y);
+ }
+
+ private static PopupWindowNative.Point Near(AnchorSide side) =>
+ PopupLayout.NearAnchor(Anchor, side, Offset, Width, Height);
+
+ [Theory]
+ [InlineData(16, 1.0, 16)]
+ [InlineData(16, 1.25, 20)]
+ [InlineData(16, 1.5, 24)]
+ [InlineData(16, 2.0, 32)]
+ [InlineData(0, 2.0, 0)]
+ [InlineData(20.4, 1.0, 20)]
+ [InlineData(20.6, 1.0, 21)]
+ public void WPF_units_turn_into_pixels_by_the_scale(double units, double scale, int expected)
+ {
+ Assert.Equal(expected, PopupLayout.ToPixels(units, scale));
+ }
+}
diff --git a/CursorLang.Tests/Services/RegistryStartupTests.cs b/CursorLang.Tests/Services/RegistryStartupTests.cs
new file mode 100644
index 0000000..5e01baa
--- /dev/null
+++ b/CursorLang.Tests/Services/RegistryStartupTests.cs
@@ -0,0 +1,156 @@
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+using Microsoft.Win32;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// Startup of a build unpacked into a folder: a value under the Run key. The tests
+/// keep to a root of their own, so the startup list of the machine is untouched.
+///
+public sealed class RegistryStartupTests
+{
+ private const string RunPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
+
+ private const string ApprovedPath =
+ @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
+
+ private const string ValueName = "CursorLang";
+
+ private const string Command = @"""C:\Apps\CursorLang\CursorLang.exe""";
+
+ [Fact]
+ public void With_nothing_written_down_startup_is_off()
+ {
+ using var root = new TempRegistryKey();
+
+ Assert.Equal(StartupState.Disabled, new RegistryStartup(root.Key, Command).GetState());
+ }
+
+ [Fact]
+ public void Switching_startup_on_writes_the_path_of_the_app()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, Command);
+
+ Assert.Equal(StartupState.Enabled, startup.SetEnabled(true));
+ Assert.Equal(Command, ReadRunValue(root));
+ }
+
+ [Fact]
+ public void Switching_startup_off_takes_the_entry_away()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, Command);
+
+ startup.SetEnabled(true);
+
+ Assert.Equal(StartupState.Disabled, startup.SetEnabled(false));
+ Assert.Null(ReadRunValue(root));
+ }
+
+ // Switching off what is already off is what happens when Windows and the app
+ // disagree about the state, and it is no reason to fail
+ [Fact]
+ public void Switching_off_startup_that_is_already_off_passes_quietly()
+ {
+ using var root = new TempRegistryKey();
+
+ Assert.Equal(StartupState.Disabled, new RegistryStartup(root.Key, Command).SetEnabled(false));
+ }
+
+ [Fact]
+ public void The_path_is_written_afresh_every_time()
+ {
+ using var root = new TempRegistryKey();
+
+ new RegistryStartup(root.Key, @"""C:\Old\CursorLang.exe""").SetEnabled(true);
+ new RegistryStartup(root.Key, Command).SetEnabled(true);
+
+ Assert.Equal(Command, ReadRunValue(root));
+ }
+
+ [Fact]
+ public void An_entry_the_user_has_banned_counts_as_off()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, Command);
+
+ startup.SetEnabled(true);
+ Ban(root);
+
+ Assert.Equal(StartupState.DisabledByUser, startup.GetState());
+ }
+
+ // The ban outlives the request: the entry is written, and Windows still ignores it
+ [Fact]
+ public void The_ban_of_the_user_survives_a_request_to_switch_startup_on()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, Command);
+
+ Ban(root);
+
+ Assert.Equal(StartupState.DisabledByUser, startup.SetEnabled(true));
+ Assert.Equal(Command, ReadRunValue(root));
+ }
+
+ [Fact]
+ public void A_verdict_of_the_user_in_favour_leaves_startup_on()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, Command);
+
+ startup.SetEnabled(true);
+ WriteVerdict(root, 0x02);
+
+ Assert.Equal(StartupState.Enabled, startup.GetState());
+ }
+
+ // An empty blob is not a ban: Windows writes twelve bytes, but a value cut
+ // short says nothing about the will of the user
+ [Fact]
+ public void A_verdict_with_no_bytes_in_it_is_no_ban()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, Command);
+
+ startup.SetEnabled(true);
+
+ using RegistryKey approved = root.Key.CreateSubKey(ApprovedPath);
+ approved.SetValue(ValueName, Array.Empty(), RegistryValueKind.Binary);
+
+ Assert.Equal(StartupState.Enabled, startup.GetState());
+ }
+
+ [Fact]
+ public void With_no_path_to_the_app_startup_is_unavailable()
+ {
+ using var root = new TempRegistryKey();
+ var startup = new RegistryStartup(root.Key, command: null);
+
+ Assert.Equal(StartupState.Unavailable, startup.GetState());
+ Assert.Equal(StartupState.Unavailable, startup.SetEnabled(true));
+ Assert.Equal(StartupState.Unavailable, startup.SetEnabled(false));
+ Assert.Null(ReadRunValue(root));
+ }
+
+ private static string? ReadRunValue(TempRegistryKey root)
+ {
+ using RegistryKey? run = root.Key.OpenSubKey(RunPath);
+ return run?.GetValue(ValueName) as string;
+ }
+
+ /// The mark Windows leaves after the user switches the entry off.
+ private static void Ban(TempRegistryKey root) => WriteVerdict(root, 0x03);
+
+ private static void WriteVerdict(TempRegistryKey root, byte first)
+ {
+ var verdict = new byte[12];
+ verdict[0] = first;
+
+ using RegistryKey approved = root.Key.CreateSubKey(ApprovedPath);
+ approved.SetValue(ValueName, verdict, RegistryValueKind.Binary);
+ }
+}
diff --git a/CursorLang.Tests/Services/SettingsServiceTests.cs b/CursorLang.Tests/Services/SettingsServiceTests.cs
new file mode 100644
index 0000000..77bcf02
--- /dev/null
+++ b/CursorLang.Tests/Services/SettingsServiceTests.cs
@@ -0,0 +1,454 @@
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+using System.Windows.Media;
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+using CursorLang.Tests.Models;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// Keeping the settings in a file. Everything happens in a temporary folder:
+/// the tests have no business touching the user's own settings.
+///
+public sealed class SettingsServiceTests
+{
+ /// The deferred write delay in tests: half a second is not worth waiting for.
+ private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(20);
+
+ [Fact]
+ public void Without_a_file_the_defaults_are_handed_out()
+ {
+ using var folder = new TempFolder();
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ return service.Load();
+ });
+
+ Assert.Equal(AppTheme.System, settings.Theme);
+ Assert.Equal(20, settings.FontSize);
+ }
+
+ [Theory]
+ [InlineData("ru", "ru")]
+ [InlineData("ru-RU", "ru")]
+ [InlineData("en-US", "en")]
+ [InlineData("de-DE", "en")]
+ [InlineData("fr", "en")]
+ public void The_default_language_follows_the_language_of_Windows(string uiCulture, string expected)
+ {
+ using var folder = new TempFolder();
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ CultureInfo previous = CultureInfo.CurrentUICulture;
+ try
+ {
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(uiCulture);
+
+ using SettingsService service = Create(folder);
+ return service.Load();
+ }
+ finally
+ {
+ CultureInfo.CurrentUICulture = previous;
+ }
+ });
+
+ Assert.Equal(expected, settings.Language);
+ }
+
+ [Fact]
+ public void Saved_settings_are_read_back()
+ {
+ using var folder = new TempFolder();
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+
+ settings.FontSize = 42;
+ settings.Theme = AppTheme.Dark;
+ settings.PlacementMode = PopupPlacementMode.AtCaret;
+ settings.BackgroundColor = Color.FromRgb(0x11, 0x22, 0x33);
+ settings.UseCapsLockHotkey = true;
+
+ service.Save();
+ });
+
+ AppSettings restored = Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ return service.Load();
+ });
+
+ Assert.Equal(42, restored.FontSize);
+ Assert.Equal(AppTheme.Dark, restored.Theme);
+ Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode);
+ Assert.Equal(Color.FromRgb(0x11, 0x22, 0x33), restored.BackgroundColor);
+ Assert.True(restored.UseCapsLockHotkey);
+ }
+
+ [Fact]
+ public void The_settings_land_in_the_file_in_a_readable_form()
+ {
+ using var folder = new TempFolder();
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+ settings.Theme = AppTheme.Dark;
+ settings.BackgroundColor = Color.FromRgb(0x20, 0x20, 0x20);
+ service.Save();
+ });
+
+ string json = File.ReadAllText(folder.File("settings.json"));
+
+ // The theme as a word rather than a number; the colour in its usual notation
+ Assert.Contains("\"Theme\": \"Dark\"", json, StringComparison.Ordinal);
+ Assert.Contains("#FF202020", json, StringComparison.Ordinal);
+
+ // And all of it across lines: the file is sometimes edited by hand
+ Assert.Contains('\n', json);
+ }
+
+ [Fact]
+ public void A_changed_setting_saves_itself()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+
+ settings.FontSize = 33;
+
+ // Right after the edit there is nothing on disk yet: the write is deferred
+ Assert.False(File.Exists(path));
+
+ Sta.WaitFor(() => File.Exists(path), "the settings were written by the timer");
+ });
+
+ Assert.Contains("\"FontSize\": 33", File.ReadAllText(path), StringComparison.Ordinal);
+ }
+
+ // A slider changes its value continuously, and writing every move to disk is pointless
+ [Fact]
+ public void A_run_of_edits_defers_the_write_until_a_pause()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150));
+ AppSettings settings = service.Load();
+
+ for (int i = 0; i < 10; i++)
+ {
+ settings.Opacity = 0.5 + (i * 0.01);
+ Assert.False(File.Exists(path));
+ Sta.Pause(TimeSpan.FromMilliseconds(10));
+ }
+
+ Sta.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
+ });
+ }
+
+ [Fact]
+ public void Closing_the_service_saves_the_latest_edits()
+ {
+ using var folder = new TempFolder();
+
+ Sta.Run(() =>
+ {
+ SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+ settings.FontSize = 27;
+
+ service.Dispose();
+ });
+
+ Assert.Contains(
+ "\"FontSize\": 27",
+ File.ReadAllText(folder.File("settings.json")),
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void After_closing_edits_no_longer_reach_the_disk()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Sta.Run(() =>
+ {
+ SettingsService service = Create(folder);
+ AppSettings settings = service.Load();
+ service.Dispose();
+
+ string afterDispose = File.ReadAllText(path);
+
+ settings.FontSize = 99;
+ Sta.Pause(TimeSpan.FromMilliseconds(60));
+
+ Assert.Equal(afterDispose, File.ReadAllText(path));
+ });
+ }
+
+ [Fact]
+ public void Settings_of_a_previous_install_are_taken_over()
+ {
+ using var folder = new TempFolder();
+ string inherited = folder.File("inherited.json");
+ string own = folder.File("settings.json");
+
+ File.WriteAllText(inherited, """{"FontSize": 31, "Language": "ru"}""");
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = new(own, inherited, SaveDelay);
+ return service.Load();
+ });
+
+ Assert.Equal(31, settings.FontSize);
+ Assert.Equal("ru", settings.Language);
+
+ // What was taken over is pinned to its new place at once rather than on the first edit
+ Assert.True(File.Exists(own));
+ Assert.Contains("\"FontSize\": 31", File.ReadAllText(own), StringComparison.Ordinal);
+ }
+
+ // Both builds may be installed side by side: the other one keeps its settings
+ [Fact]
+ public void The_previous_install_does_not_lose_its_settings()
+ {
+ using var folder = new TempFolder();
+ string inherited = folder.File("inherited.json");
+ string original = """{"FontSize": 31}""";
+
+ File.WriteAllText(inherited, original);
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = new(folder.File("settings.json"), inherited, SaveDelay);
+ _ = service.Load();
+ });
+
+ Assert.Equal(original, File.ReadAllText(inherited));
+ }
+
+ [Fact]
+ public void Own_settings_outweigh_those_of_a_previous_install()
+ {
+ using var folder = new TempFolder();
+ string own = folder.File("settings.json");
+ string inherited = folder.File("inherited.json");
+
+ File.WriteAllText(own, """{"FontSize": 12}""");
+ File.WriteAllText(inherited, """{"FontSize": 31}""");
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = new(own, inherited, SaveDelay);
+ return service.Load();
+ });
+
+ Assert.Equal(12, settings.FontSize);
+ }
+
+ // Outside a package both paths are the same, so there is nothing to take over
+ [Fact]
+ public void Without_a_package_no_settings_are_taken_over()
+ {
+ using var folder = new TempFolder();
+ string path = folder.File("settings.json");
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = new(path, path, SaveDelay);
+ _ = service.Load();
+
+ // No file appeared: there was nothing to take over and nowhere to take it from
+ Assert.False(File.Exists(path));
+ });
+ }
+
+ [Fact]
+ public void A_broken_settings_file_does_not_bring_the_app_down()
+ {
+ using var folder = new TempFolder();
+ File.WriteAllText(folder.File("settings.json"), "{this is not json");
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ return service.Load();
+ });
+
+ Assert.Equal(20, settings.FontSize);
+ }
+
+ [Fact]
+ public void Settings_with_unknown_fields_are_still_read()
+ {
+ using var folder = new TempFolder();
+ File.WriteAllText(folder.File("settings.json"), """{"FontSize": 15, "SomethingNew": true}""");
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ return service.Load();
+ });
+
+ Assert.Equal(15, settings.FontSize);
+ }
+
+ [Theory]
+ [InlineData("\"#FF102030\"", 0x10, 0x20, 0x30)]
+ [InlineData("\"#102030\"", 0x10, 0x20, 0x30)]
+ [InlineData("\"Red\"", 0xFF, 0x00, 0x00)]
+ public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b)
+ {
+ using var folder = new TempFolder();
+ File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ return service.Load();
+ });
+
+ Assert.Equal(Color.FromRgb(r, g, b), settings.BackgroundColor);
+ }
+
+ [Theory]
+ [InlineData("\"\"")]
+ [InlineData("\" \"")]
+ [InlineData("\"not a colour\"")]
+ public void An_unintelligible_colour_becomes_black(string stored)
+ {
+ using var folder = new TempFolder();
+ File.WriteAllText(folder.File("settings.json"), $$"""{"BackgroundColor": {{stored}}}""");
+
+ AppSettings settings = Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ return service.Load();
+ });
+
+ Assert.Equal(Colors.Black, settings.BackgroundColor);
+ }
+
+ // The service creates the settings folder itself
+ [Fact]
+ public void The_settings_folder_is_created_on_write()
+ {
+ using var folder = new TempFolder();
+ string nested = Path.Combine(folder.Path, "a", "b", "settings.json");
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = new(nested, folder.File("inherited.json"), SaveDelay);
+ _ = service.Load();
+ service.Save();
+ });
+
+ Assert.True(File.Exists(nested));
+ }
+
+ // Settings are not the kind of thing worth bringing the app down for
+ [Fact]
+ public void A_path_that_cannot_be_written_does_not_bring_the_app_down()
+ {
+ using var folder = new TempFolder();
+
+ // A folder sits where the settings file should be: writing there will not work
+ string path = folder.File("settings.json");
+ Directory.CreateDirectory(path);
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay);
+ AppSettings settings = service.Load();
+
+ settings.FontSize = 18;
+ service.Save();
+ });
+
+ Assert.True(Directory.Exists(path));
+ }
+
+ [Fact]
+ public void Saving_without_loading_writes_nothing()
+ {
+ using var folder = new TempFolder();
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ service.Save();
+ });
+
+ Assert.False(File.Exists(folder.File("settings.json")));
+ }
+
+ [Fact]
+ public void Closing_without_loading_passes_without_consequence()
+ {
+ using var folder = new TempFolder();
+
+ Sta.Run(() =>
+ {
+ SettingsService service = Create(folder);
+ service.Dispose();
+ });
+
+ Assert.False(File.Exists(folder.File("settings.json")));
+ }
+
+ [Fact]
+ public void Every_setting_of_the_app_reaches_the_file()
+ {
+ using var folder = new TempFolder();
+
+ Sta.Run(() =>
+ {
+ using SettingsService service = Create(folder);
+ _ = service.Load();
+ service.Save();
+ });
+
+ using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json")));
+ List stored = [.. document.RootElement.EnumerateObject().Select(property => property.Name)];
+
+ foreach (string name in AppSettingsTests.WritablePropertyNames())
+ {
+ Assert.Contains(name, stored);
+ }
+ }
+
+ // An ordinary run picks the storage place itself: a package keeps settings
+ // of its own, a separate install keeps them in the user profile
+ [Fact]
+ public void The_storage_place_is_chosen_on_its_own()
+ {
+ Sta.Run(() =>
+ {
+ // Nothing is read and nothing is written: only the fact that a path
+ // gets chosen without error is under test
+ using var service = new SettingsService();
+ });
+ }
+
+ private static SettingsService Create(TempFolder folder) =>
+ new(folder.File("settings.json"), folder.File("inherited.json"), SaveDelay);
+}
diff --git a/CursorLang.Tests/Services/SingleInstanceGateTests.cs b/CursorLang.Tests/Services/SingleInstanceGateTests.cs
new file mode 100644
index 0000000..e559f93
--- /dev/null
+++ b/CursorLang.Tests/Services/SingleInstanceGateTests.cs
@@ -0,0 +1,204 @@
+using System.Collections.Concurrent;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// The place of the single instance. The kernel object names in the tests are
+/// their own: sharing them with a running application is not an option.
+///
+public sealed class SingleInstanceGateTests
+{
+ [Fact]
+ public void The_first_run_takes_the_place()
+ {
+ string suffix = UniqueSuffix();
+ SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
+
+ try
+ {
+ Assert.True(Sta.Run(gate.TryAcquire));
+ }
+ finally
+ {
+ Sta.Run(gate.Dispose);
+ }
+ }
+
+ [Fact]
+ public void The_second_run_does_not_get_the_place()
+ {
+ string suffix = UniqueSuffix();
+ SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+
+ try
+ {
+ Assert.True(Sta.Run(first.TryAcquire));
+ Assert.False(TryAcquireApart(suffix));
+ }
+ finally
+ {
+ Sta.Run(first.Dispose);
+ }
+ }
+
+ [Fact]
+ public void The_second_run_asks_the_running_one_to_show_its_window()
+ {
+ string suffix = UniqueSuffix();
+ SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+
+ ConcurrentQueue requests = new();
+ first.ActivationRequested += (_, e) => requests.Enqueue(e);
+
+ try
+ {
+ Sta.Run(first.TryAcquire);
+ Assert.False(TryAcquireApart(suffix));
+
+ Sta.WaitFor(() => !requests.IsEmpty, "the running instance got the request to show itself");
+ }
+ finally
+ {
+ Sta.Run(first.Dispose);
+ }
+ }
+
+ [Fact]
+ public void Without_a_second_run_no_request_arrives()
+ {
+ string suffix = UniqueSuffix();
+ SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
+
+ ConcurrentQueue requests = new();
+ gate.ActivationRequested += (_, e) => requests.Enqueue(e);
+
+ try
+ {
+ Sta.Run(gate.TryAcquire);
+ Sta.Pause(TimeSpan.FromMilliseconds(80));
+
+ Assert.Empty(requests);
+ }
+ finally
+ {
+ Sta.Run(gate.Dispose);
+ }
+ }
+
+ // The place is released on exit — otherwise the app would never start again
+ [Fact]
+ public void After_the_exit_the_place_is_free_again()
+ {
+ string suffix = UniqueSuffix();
+
+ SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+ Assert.True(Sta.Run(first.TryAcquire));
+ Sta.Run(first.Dispose);
+
+ Assert.True(TryAcquireApart(suffix));
+ }
+
+ [Fact]
+ public void No_requests_arrive_after_the_exit()
+ {
+ string suffix = UniqueSuffix();
+ SingleInstanceGate first = Sta.Run(() => new SingleInstanceGate(suffix));
+
+ ConcurrentQueue requests = new();
+ first.ActivationRequested += (_, e) => requests.Enqueue(e);
+
+ Sta.Run(first.TryAcquire);
+ Sta.Run(first.Dispose);
+
+ // The place is free, so the new run simply takes it for itself
+ Assert.True(TryAcquireApart(suffix));
+ Sta.Pause(TimeSpan.FromMilliseconds(80));
+
+ Assert.Empty(requests);
+ }
+
+ // The previous instance crashed and did not release the place. It has no
+ // owner any more, which means the place is free
+ [Fact]
+ public void A_place_left_by_a_crash_counts_as_free()
+ {
+ string suffix = UniqueSuffix();
+
+ // A thread that took the mutex and ended without releasing it is exactly
+ // what a crashed application looks like to Windows
+ Sta.RunApart(() =>
+ {
+ var abandoned = new Mutex(initiallyOwned: false, "CursorLang.SingleInstance" + suffix);
+ abandoned.WaitOne(TimeSpan.Zero, exitContext: false);
+ });
+
+ SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(suffix));
+
+ try
+ {
+ Assert.True(Sta.Run(gate.TryAcquire));
+ }
+ finally
+ {
+ Sta.Run(gate.Dispose);
+ }
+ }
+
+ [Fact]
+ public void Closing_without_taking_the_place_passes_without_consequence()
+ {
+ SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(UniqueSuffix()));
+
+ Sta.Run(gate.Dispose);
+ }
+
+ // The application takes the place under its ordinary name
+ [Fact]
+ public void The_ordinary_application_takes_the_place_under_its_own_name()
+ {
+ SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate());
+
+ // The place may be held by a running application — then it is simply not taken
+ Sta.Run(gate.Dispose);
+ }
+
+ [Fact]
+ public void Closing_twice_passes_without_consequence()
+ {
+ SingleInstanceGate gate = Sta.Run(() => new SingleInstanceGate(UniqueSuffix()));
+
+ Sta.Run(gate.TryAcquire);
+ Sta.Run(gate.Dispose);
+ Sta.Run(gate.Dispose);
+ }
+
+ // Every test gets its own namespace of kernel objects
+ private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
+
+ ///
+ /// Tries to take the place the way a run started afterwards does it —
+ /// from another thread rather than from the same one.
+ ///
+ private static bool TryAcquireApart(string suffix)
+ {
+ bool acquired = false;
+
+ Sta.RunApart(() =>
+ {
+ var gate = new SingleInstanceGate(suffix);
+
+ try
+ {
+ acquired = gate.TryAcquire();
+ }
+ finally
+ {
+ gate.Dispose();
+ }
+ });
+
+ return acquired;
+ }
+}
diff --git a/CursorLang.Tests/Services/StartupServiceTests.cs b/CursorLang.Tests/Services/StartupServiceTests.cs
new file mode 100644
index 0000000..3567556
--- /dev/null
+++ b/CursorLang.Tests/Services/StartupServiceTests.cs
@@ -0,0 +1,55 @@
+using Windows.ApplicationModel;
+using CursorLang.Interop;
+using CursorLang.Models;
+using CursorLang.Services;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// Startup by way of Windows. The tests run outside an MSIX package — as does any
+/// run of the app from a folder — so the answer they get comes from the registry.
+///
+public sealed class StartupServiceTests
+{
+ [Fact]
+ public async Task Outside_a_package_the_state_comes_from_the_registry()
+ {
+ if (PackageIdentityNative.IsPackaged)
+ {
+ // The tests are running as a package: the answer comes from the task instead
+ return;
+ }
+
+ // Whether startup is on depends on the machine; what matters is that the
+ // question is answered at all and the setting is not hidden
+ Assert.NotEqual(StartupState.Unavailable, await new StartupService().GetStateAsync());
+ }
+
+ [Theory]
+ [InlineData(StartupTaskState.Enabled, StartupState.Enabled)]
+ [InlineData(StartupTaskState.Disabled, StartupState.Disabled)]
+ [InlineData(StartupTaskState.DisabledByUser, StartupState.DisabledByUser)]
+ [InlineData(StartupTaskState.DisabledByPolicy, StartupState.DisabledByPolicy)]
+ [InlineData(StartupTaskState.EnabledByPolicy, StartupState.EnabledByPolicy)]
+ public void A_Windows_task_state_translates_into_an_app_state(
+ StartupTaskState windows, StartupState expected)
+ {
+ Assert.Equal(expected, StartupService.Translate(windows));
+ }
+
+ // Windows may grow a state the app knows nothing about
+ [Fact]
+ public void An_unfamiliar_state_counts_as_unavailable()
+ {
+ Assert.Equal(StartupState.Unavailable, StartupService.Translate((StartupTaskState)999));
+ }
+
+ [Fact]
+ public void No_Windows_state_is_left_behind()
+ {
+ foreach (StartupTaskState state in Enum.GetValues())
+ {
+ Assert.NotEqual(StartupState.Unavailable, StartupService.Translate(state));
+ }
+ }
+}
diff --git a/CursorLang.Tests/Services/ThemeServiceTests.cs b/CursorLang.Tests/Services/ThemeServiceTests.cs
new file mode 100644
index 0000000..7e614df
--- /dev/null
+++ b/CursorLang.Tests/Services/ThemeServiceTests.cs
@@ -0,0 +1,454 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using CursorLang.Models;
+using CursorLang.Services;
+using CursorLang.Tests.Infrastructure;
+using Microsoft.Win32;
+
+namespace CursorLang.Tests.Services;
+
+///
+/// The look of the windows. The palette lives in the application resources,
+/// so everything happens on the interface thread.
+///
+public sealed class ThemeServiceTests
+{
+ [Theory]
+ [InlineData(AppTheme.Light)]
+ [InlineData(AppTheme.Dark)]
+ public void A_chosen_theme_is_applied_as_is(AppTheme theme)
+ {
+ var settings = new AppSettings { Theme = theme };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Dark);
+
+ Assert.Equal(theme, service.CurrentTheme);
+ });
+ }
+
+ [Theory]
+ [InlineData(AppTheme.Light)]
+ [InlineData(AppTheme.Dark)]
+ public void The_system_theme_is_taken_from_Windows(AppTheme system)
+ {
+ var settings = new AppSettings { Theme = AppTheme.System };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, () => system);
+
+ Assert.Equal(system, service.CurrentTheme);
+ });
+ }
+
+ [Fact]
+ public void Changing_the_theme_in_the_settings_repaints_the_windows()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Light };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Light);
+
+ Color light = WindowBackground();
+
+ settings.Theme = AppTheme.Dark;
+
+ Assert.Equal(AppTheme.Dark, service.CurrentTheme);
+ Assert.NotEqual(light, WindowBackground());
+ });
+ }
+
+ // The palette is replaced rather than piled up: otherwise the light one
+ // would still sit under the dark one
+ [Fact]
+ public void The_palette_does_not_pile_up_in_the_resources()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Light };
+
+ Sta.Run(() =>
+ {
+ int before = Application.Current.Resources.MergedDictionaries.Count;
+
+ using var service = new ThemeService(settings, static () => AppTheme.Light);
+
+ settings.Theme = AppTheme.Dark;
+ settings.Theme = AppTheme.Light;
+ settings.Theme = AppTheme.Dark;
+
+ Assert.Equal(before + 1, Application.Current.Resources.MergedDictionaries.Count);
+ });
+ }
+
+ [Fact]
+ public void Choosing_the_same_theme_again_leaves_the_resources_alone()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Dark };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Light);
+
+ int count = Application.Current.Resources.MergedDictionaries.Count;
+ ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
+
+ settings.Theme = AppTheme.Dark;
+
+ Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
+ Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
+ });
+ }
+
+ // The other settings have nothing to do with the look
+ [Fact]
+ public void Other_settings_do_not_change_the_theme()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Light };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Dark);
+
+ settings.FontSize = 40;
+ settings.Language = "ru";
+
+ Assert.Equal(AppTheme.Light, service.CurrentTheme);
+ });
+ }
+
+ [Fact]
+ public void A_window_that_already_exists_is_attached_at_once()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Dark };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Light);
+ var window = new Window();
+
+ try
+ {
+ _ = new WindowInteropHelper(window).EnsureHandle();
+
+ // The title bar is painted by Windows, and the only way to check
+ // this is that the call goes through without an error
+ service.Register(window);
+ }
+ finally
+ {
+ window.Close();
+ }
+ });
+ }
+
+ [Fact]
+ public void A_window_without_a_handle_is_attached_once_it_appears()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Dark };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Light);
+ var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
+
+ try
+ {
+ service.Register(window);
+
+ // The window is created on show — and the look comes with it
+ window.Show();
+ }
+ finally
+ {
+ window.Close();
+ }
+ });
+ }
+
+ // The ordinary service takes the theme from the Windows settings
+ [Fact]
+ public void The_service_can_work_with_the_real_Windows_theme()
+ {
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(new AppSettings { Theme = AppTheme.System });
+
+ Assert.True(service.CurrentTheme is AppTheme.Light or AppTheme.Dark);
+ Assert.Equal(ThemeService.DetectSystemTheme(), service.CurrentTheme);
+ });
+ }
+
+ // Windows reports a look change from a thread other than the interface one
+ [Fact]
+ public void A_look_change_in_Windows_repaints_the_windows()
+ {
+ var settings = new AppSettings { Theme = AppTheme.System };
+ AppTheme system = AppTheme.Light;
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, () => system);
+ Assert.Equal(AppTheme.Light, service.CurrentTheme);
+
+ system = AppTheme.Dark;
+ RaiseUserPreferenceChanged(service);
+
+ Sta.WaitFor(() => service.CurrentTheme == AppTheme.Dark, "the theme was recomputed at the request of Windows");
+ });
+ }
+
+ // Windows reports a look change even when nothing changed for the app:
+ // in that case there is nothing to repaint
+ [Fact]
+ public void The_same_theme_does_not_replace_the_resources()
+ {
+ var settings = new AppSettings { Theme = AppTheme.System };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Dark);
+
+ ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
+ int count = Application.Current.Resources.MergedDictionaries.Count;
+
+ RaiseUserPreferenceChanged(service);
+ Sta.Pause(TimeSpan.FromMilliseconds(30));
+
+ Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
+ Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
+ });
+ }
+
+ [Fact]
+ public void A_closed_window_is_forgotten()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Light };
+
+ Sta.Run(() =>
+ {
+ using var service = new ThemeService(settings, static () => AppTheme.Light);
+ var window = new Window();
+ _ = new WindowInteropHelper(window).EnsureHandle();
+
+ service.Register(window);
+ window.Close();
+
+ // A theme change must no longer concern the closed window
+ settings.Theme = AppTheme.Dark;
+
+ Assert.Equal(AppTheme.Dark, service.CurrentTheme);
+ });
+ }
+
+ [Fact]
+ public void Closing_the_service_lets_go_of_the_attached_windows()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Light };
+
+ Sta.Run(() =>
+ {
+ var service = new ThemeService(settings, static () => AppTheme.Light);
+ var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
+
+ try
+ {
+ window.Show();
+ service.Register(window);
+
+ service.Dispose();
+
+ // The window now closes on its own, with no regard for the theme
+ window.Close();
+ }
+ finally
+ {
+ window.Close();
+ }
+ });
+ }
+
+ [Fact]
+ public void After_the_service_is_closed_the_setting_no_longer_changes_the_theme()
+ {
+ var settings = new AppSettings { Theme = AppTheme.Light };
+
+ Sta.Run(() =>
+ {
+ var service = new ThemeService(settings, static () => AppTheme.Light);
+ service.Dispose();
+
+ settings.Theme = AppTheme.Dark;
+
+ Assert.Equal(AppTheme.Light, service.CurrentTheme);
+ });
+ }
+
+ [Fact]
+ public void After_the_service_is_closed_requests_from_Windows_go_unanswered()
+ {
+ var settings = new AppSettings { Theme = AppTheme.System };
+ AppTheme system = AppTheme.Light;
+
+ Sta.Run(() =>
+ {
+ var service = new ThemeService(settings, () => system);
+ service.Dispose();
+
+ system = AppTheme.Dark;
+ Sta.Pause(TimeSpan.FromMilliseconds(50));
+
+ Assert.Equal(AppTheme.Light, service.CurrentTheme);
+ });
+ }
+
+ [Theory]
+ [InlineData(AppTheme.Light)]
+ [InlineData(AppTheme.Dark)]
+ public void The_palette_of_each_theme_lives_in_the_application_assembly(AppTheme theme)
+ {
+ Sta.Run(() =>
+ {
+ var palette = new ResourceDictionary { Source = ThemeService.PaletteUri(theme) };
+
+ Assert.NotEmpty(palette.Keys);
+ Assert.True(palette.Contains("Theme.WindowBackground"));
+ });
+ }
+
+ // The palette address names the application assembly rather than the one
+ // the process started from
+ [Fact]
+ public void The_palette_address_names_the_application_assembly()
+ {
+ Assert.Contains("CursorLang;component", ThemeService.PaletteUri(AppTheme.Dark).ToString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void The_light_and_dark_palettes_share_one_set_of_keys()
+ {
+ Sta.Run(() =>
+ {
+ var light = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Light) };
+ var dark = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Dark) };
+
+ Assert.Equal(light.Keys.Cast