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().OrderBy(key => key.ToString()), + dark.Keys.Cast().OrderBy(key => key.ToString())); + }); + } + + [Fact] + public void The_Windows_theme_is_read_without_errors() + { + AppTheme theme = ThemeService.DetectSystemTheme(); + + // The "follow the system" setting has to yield something definite + Assert.True(theme is AppTheme.Light or AppTheme.Dark); + } + + // A control the shared dictionary says nothing about keeps the look Windows + // gives it and stays light in the dark theme + [Theory] + [InlineData(typeof(Button))] + [InlineData(typeof(ComboBox))] + [InlineData(typeof(CheckBox))] + [InlineData(typeof(GroupBox))] + [InlineData(typeof(ProgressBar))] + [InlineData(typeof(Slider))] + public void A_control_of_the_window_is_repainted_together_with_the_theme(Type control) + { + var settings = new AppSettings { Theme = AppTheme.Light }; + + Sta.Run(() => + { + using var service = new ThemeService(settings, static () => AppTheme.Light); + + var window = new Window + { + Opacity = 0, + ShowInTaskbar = false, + ShowActivated = false, + Width = 200, + Height = 100, + Content = (Control)Activator.CreateInstance(control)!, + }; + + try + { + window.Show(); + window.UpdateLayout(); + + IReadOnlyList light = PaintOf(window); + Assert.NotEmpty(light); + + settings.Theme = AppTheme.Dark; + window.UpdateLayout(); + + Assert.NotEqual(light, PaintOf(window)); + } + finally + { + window.Close(); + } + }); + } + + // Windows reports a look change through an event that cannot be synthesised: + // the test goes straight to the handler that event arrives at + private static void RaiseUserPreferenceChanged(ThemeService service) + { + System.Reflection.MethodInfo handler = typeof(ThemeService) + .GetMethod("OnUserPreferenceChanged", System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic)!; + + handler.Invoke(service, [null, new UserPreferenceChangedEventArgs(UserPreferenceCategory.General)]); + } + + private static Color WindowBackground() => + ((SolidColorBrush)Application.Current.Resources["Theme.WindowBackground"]).Color; + + /// + /// Every colour the element tree is painted with. What is compared is the + /// whole set: which part of a control the palette reaches is its own business. + /// + private static IReadOnlyList PaintOf(DependencyObject root) + { + var colours = new List(); + Collect(root, colours); + + return colours; + } + + private static void Collect(DependencyObject node, List colours) + { + switch (node) + { + case Control control: + Add(colours, control.Background, control.BorderBrush, control.Foreground); + break; + case Border border: + Add(colours, border.Background, border.BorderBrush); + break; + case Shape shape: + Add(colours, shape.Fill, shape.Stroke); + break; + case TextBlock text: + Add(colours, text.Background, text.Foreground); + break; + } + + int count = VisualTreeHelper.GetChildrenCount(node); + + for (int i = 0; i < count; i++) + { + Collect(VisualTreeHelper.GetChild(node, i), colours); + } + } + + private static void Add(List colours, params Brush?[] brushes) => + colours.AddRange(brushes.OfType().Select(brush => brush.Color)); +} diff --git a/CursorLang.Tests/ViewModels/EnumOptionTests.cs b/CursorLang.Tests/ViewModels/EnumOptionTests.cs new file mode 100644 index 0000000..d86c731 --- /dev/null +++ b/CursorLang.Tests/ViewModels/EnumOptionTests.cs @@ -0,0 +1,56 @@ +using CursorLang.Models; +using CursorLang.ViewModels; + +namespace CursorLang.Tests.ViewModels; + +public sealed class EnumOptionTests +{ + [Fact] + public void An_option_remembers_its_value_and_caption() + { + var option = new EnumOption(AppTheme.Dark, "Dark theme"); + + Assert.Equal(AppTheme.Dark, option.Value); + Assert.Equal("Dark theme", option.Display); + } + + [Fact] + public void A_changed_caption_is_announced_to_subscribers() + { + var option = new EnumOption(AppTheme.Dark, "Dark"); + List changed = []; + option.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + option.Display = "Dark theme"; + + Assert.Equal("Dark theme", option.Display); + Assert.Equal([nameof(EnumOption<>.Display)], changed); + } + + [Fact] + public void The_same_caption_is_not_announced_again() + { + var option = new EnumOption(AppTheme.Dark, "Dark theme"); + List changed = []; + option.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + option.Display = "Dark theme"; + + Assert.Empty(changed); + } + + // Accessibility tools take the name of a list item from here + [Fact] + public void An_option_presents_itself_by_its_caption() + { + Assert.Equal("Dark theme", new EnumOption(AppTheme.Dark, "Dark theme").ToString()); + } + + [Fact] + public void A_changed_caption_changes_the_presentation_too() + { + var option = new EnumOption(ScreenPosition.Center, "Center") { Display = "In the centre" }; + + Assert.Equal("In the centre", option.ToString()); + } +} diff --git a/CursorLang.Tests/ViewModels/LayoutPopupViewModelTests.cs b/CursorLang.Tests/ViewModels/LayoutPopupViewModelTests.cs new file mode 100644 index 0000000..163051b --- /dev/null +++ b/CursorLang.Tests/ViewModels/LayoutPopupViewModelTests.cs @@ -0,0 +1,48 @@ +using CursorLang.Models; +using CursorLang.ViewModels; + +namespace CursorLang.Tests.ViewModels; + +public sealed class LayoutPopupViewModelTests +{ + // Before the first layout change there is nothing to show, yet the window + // is already being built + [Fact] + public void Before_the_first_layout_a_dash_is_shown() + { + Assert.Equal("—", new LayoutPopupViewModel(new AppSettings()).ShortName); + } + + [Fact] + public void The_look_comes_straight_from_the_settings() + { + var settings = new AppSettings(); + + Assert.Same(settings, new LayoutPopupViewModel(settings).Settings); + } + + [Fact] + public void A_changed_layout_name_is_announced_to_subscribers() + { + var viewModel = new LayoutPopupViewModel(new AppSettings()); + List changed = []; + viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + viewModel.ShortName = "RU"; + + Assert.Equal("RU", viewModel.ShortName); + Assert.Equal([nameof(LayoutPopupViewModel.ShortName)], changed); + } + + [Fact] + public void The_same_layout_name_is_not_announced_again() + { + var viewModel = new LayoutPopupViewModel(new AppSettings()) { ShortName = "RU" }; + List changed = []; + viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + viewModel.ShortName = "RU"; + + Assert.Empty(changed); + } +} diff --git a/CursorLang.Tests/ViewModels/SettingsViewModelTests.cs b/CursorLang.Tests/ViewModels/SettingsViewModelTests.cs new file mode 100644 index 0000000..f04888f --- /dev/null +++ b/CursorLang.Tests/ViewModels/SettingsViewModelTests.cs @@ -0,0 +1,291 @@ +using System.Windows.Media; +using CursorLang.Models; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; +using CursorLang.ViewModels; + +namespace CursorLang.Tests.ViewModels; + +/// +/// The settings window in terms of what it shows and what it is in charge of. +/// +public sealed class SettingsViewModelTests +{ + [Fact] + public void The_interface_language_comes_from_the_settings() + { + var settings = new AppSettings { Language = "ru" }; + var localization = new FakeLocalizationService(); + + using var viewModel = Create(settings, localization); + + Assert.Equal("ru", localization.CurrentLanguage); + Assert.Same(settings, viewModel.Settings); + Assert.Same(localization, viewModel.Localization); + } + + [Fact] + public void Changing_the_language_setting_switches_the_interface() + { + var settings = new AppSettings { Language = "en" }; + var localization = new FakeLocalizationService(); + using var viewModel = Create(settings, localization); + + settings.Language = "ru"; + + Assert.Equal("ru", localization.CurrentLanguage); + } + + [Fact] + public void Other_settings_leave_the_language_alone() + { + var settings = new AppSettings { Language = "en" }; + var localization = new FakeLocalizationService(); + using var viewModel = Create(settings, localization); + + settings.FontSize = 30; + + Assert.Equal("en", localization.CurrentLanguage); + } + + [Fact] + public void The_lists_are_built_from_every_value_of_the_enums() + { + using SettingsViewModel viewModel = Create(); + + Assert.Equal(Enum.GetValues(), viewModel.Themes.Select(option => option.Value)); + Assert.Equal(Enum.GetValues(), viewModel.PlacementModes.Select(option => option.Value)); + Assert.Equal(Enum.GetValues(), viewModel.AnchorSides.Select(option => option.Value)); + Assert.Equal(Enum.GetValues(), viewModel.ScreenPositions.Select(option => option.Value)); + } + + // A caption key is built from the type name and the value + [Fact] + public void The_option_captions_come_from_the_resources() + { + var localization = new FakeLocalizationService(); + using var viewModel = Create(localization: localization); + + Assert.Equal("en:AppTheme_System", viewModel.Themes[0].Display); + Assert.Contains("PopupPlacementMode_AtCursor", localization.RequestedKeys); + } + + // If the list items were recreated, the ComboBox would drop the selected value + [Fact] + public void Changing_the_language_changes_the_captions_not_the_options() + { + var settings = new AppSettings { Language = "en" }; + var localization = new FakeLocalizationService(); + using var viewModel = Create(settings, localization); + + EnumOption first = viewModel.Themes[0]; + + settings.Language = "ru"; + + Assert.Same(first, viewModel.Themes[0]); + Assert.Equal("ru:AppTheme_System", first.Display); + Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].Display); + Assert.Equal("ru:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display); + Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display); + } + + [Fact] + public void The_background_and_text_palettes_are_non_empty_and_different() + { + using SettingsViewModel viewModel = Create(); + + Assert.NotEmpty(viewModel.BackgroundPalette); + Assert.NotEmpty(viewModel.TextPalette); + Assert.NotEqual(viewModel.BackgroundPalette, viewModel.TextPalette); + } + + [Fact] + public void The_palettes_hold_no_duplicates() + { + using SettingsViewModel viewModel = Create(); + + Assert.Equal(viewModel.BackgroundPalette.Count, viewModel.BackgroundPalette.Distinct().Count()); + Assert.Equal(viewModel.TextPalette.Count, viewModel.TextPalette.Distinct().Count()); + } + + [Fact] + public void The_default_colours_are_present_in_the_palettes() + { + var settings = new AppSettings(); + using var viewModel = Create(settings); + + Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette); + Assert.Contains(settings.ForegroundColor, viewModel.TextPalette); + } + + [Fact] + public void The_palettes_are_the_same_for_every_window() + { + using SettingsViewModel first = Create(); + using SettingsViewModel second = Create(); + + Assert.Same(first.BackgroundPalette, second.BackgroundPalette); + Assert.Same(first.TextPalette, second.TextPalette); + } + + [Fact] + public void The_background_palette_consists_of_colours() + { + using SettingsViewModel viewModel = Create(); + + Assert.All(viewModel.BackgroundPalette, color => Assert.IsType(color)); + } + + [Fact] + public void Until_Windows_answers_the_startup_setting_stays_hidden() + { + using SettingsViewModel viewModel = Create(); + + Assert.False(viewModel.IsStartupAvailable); + Assert.False(viewModel.CanChangeStartup); + Assert.False(viewModel.IsStartupLocked); + Assert.False(viewModel.RunAtStartup); + } + + [Theory] + [InlineData(StartupState.Enabled, true, true, false, true)] + [InlineData(StartupState.Disabled, true, true, false, false)] + [InlineData(StartupState.DisabledByUser, true, false, true, false)] + [InlineData(StartupState.DisabledByPolicy, true, false, true, false)] + [InlineData(StartupState.EnabledByPolicy, true, false, true, true)] + [InlineData(StartupState.Unavailable, false, false, false, false)] + public async Task The_startup_state_decides_how_the_setting_looks( + StartupState state, bool available, bool canChange, bool locked, bool enabled) + { + var startup = new FakeStartupService { State = state }; + using var viewModel = Create(startup: startup); + + await viewModel.InitializeAsync(); + + Assert.Equal(available, viewModel.IsStartupAvailable); + Assert.Equal(canChange, viewModel.CanChangeStartup); + Assert.Equal(locked, viewModel.IsStartupLocked); + Assert.Equal(enabled, viewModel.RunAtStartup); + } + + [Fact] + public async Task The_startup_setting_is_announced_after_Windows_answers() + { + var startup = new FakeStartupService { State = StartupState.Enabled }; + using var viewModel = Create(startup: startup); + + List changed = []; + viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + await viewModel.InitializeAsync(); + + Assert.Contains(nameof(SettingsViewModel.RunAtStartup), changed); + Assert.Contains(nameof(SettingsViewModel.IsStartupAvailable), changed); + Assert.Contains(nameof(SettingsViewModel.CanChangeStartup), changed); + Assert.Contains(nameof(SettingsViewModel.IsStartupLocked), changed); + } + + [Fact] + public async Task Enabling_startup_reaches_Windows() + { + var startup = new FakeStartupService { State = StartupState.Disabled }; + using var viewModel = Create(startup: startup); + await viewModel.InitializeAsync(); + + viewModel.RunAtStartup = true; + + await WaitForStartupRequests(startup, 1); + Assert.Equal([true], startup.Requests); + Assert.True(viewModel.RunAtStartup); + } + + [Fact] + public async Task Disabling_startup_reaches_Windows() + { + var startup = new FakeStartupService { State = StartupState.Enabled }; + using var viewModel = Create(startup: startup); + await viewModel.InitializeAsync(); + + viewModel.RunAtStartup = false; + + await WaitForStartupRequests(startup, 1); + Assert.Equal([false], startup.Requests); + Assert.False(viewModel.RunAtStartup); + } + + [Fact] + public async Task Setting_the_same_value_again_leaves_Windows_alone() + { + var startup = new FakeStartupService { State = StartupState.Enabled }; + using var viewModel = Create(startup: startup); + await viewModel.InitializeAsync(); + + viewModel.RunAtStartup = true; + + Assert.Empty(startup.Requests); + } + + // A ban by the user is not for the app to argue with: the tick has to come back + [Fact] + public async Task A_refused_request_puts_the_tick_back() + { + var startup = new FakeStartupService + { + State = StartupState.Disabled, + AnswerOnEnable = StartupState.DisabledByUser, + }; + + using var viewModel = Create(startup: startup); + await viewModel.InitializeAsync(); + + List changed = []; + viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + viewModel.RunAtStartup = true; + + await WaitForStartupRequests(startup, 1); + + Assert.False(viewModel.RunAtStartup); + Assert.True(viewModel.IsStartupLocked); + Assert.Contains(nameof(SettingsViewModel.RunAtStartup), changed); + } + + [Fact] + public void Closing_unsubscribes_from_the_settings_and_the_language() + { + var settings = new AppSettings { Language = "en" }; + var localization = new FakeLocalizationService(); + SettingsViewModel viewModel = Create(settings, localization); + + EnumOption option = viewModel.Themes[0]; + string display = option.Display; + + viewModel.Dispose(); + + settings.Language = "ru"; + + // Neither the interface language nor the option captions change any more + Assert.Equal("en", localization.CurrentLanguage); + Assert.Equal(display, option.Display); + } + + private static SettingsViewModel Create( + AppSettings? settings = null, + ILocalizationService? localization = null, + IStartupService? startup = null) => + new(settings ?? new AppSettings(), + localization ?? new FakeLocalizationService(), + startup ?? new FakeStartupService(), + Fake.Updates()); + + // The setting travels to Windows without being awaited: the window must not freeze + private static async Task WaitForStartupRequests(FakeStartupService startup, int count) + { + for (int i = 0; i < 100 && startup.Requests.Count < count; i++) + { + await Task.Delay(5); + } + + Assert.Equal(count, startup.Requests.Count); + } +} diff --git a/CursorLang.Tests/Views/ConvertersTests.cs b/CursorLang.Tests/Views/ConvertersTests.cs new file mode 100644 index 0000000..d1e7239 --- /dev/null +++ b/CursorLang.Tests/Views/ConvertersTests.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +using CursorLang.Models; +using CursorLang.Views; + +namespace CursorLang.Tests.Views; + +/// +/// The binding converters: they decide what the settings window shows and what +/// it keeps out of sight. +/// +public sealed class ConvertersTests +{ + private static readonly CultureInfo Culture = CultureInfo.InvariantCulture; + + [Fact] + public void A_match_with_the_single_listed_value_shows_the_element() + { + var converter = new EnumToVisibilityConverter(); + + Assert.Equal( + Visibility.Visible, + converter.Convert(PopupPlacementMode.AtCursor, typeof(Visibility), "AtCursor", Culture)); + } + + // The anchor settings suit two placement modes at once + [Fact] + public void A_match_with_one_of_the_listed_values_shows_the_element() + { + var converter = new EnumToVisibilityConverter(); + + Assert.Equal( + Visibility.Visible, + converter.Convert(PopupPlacementMode.AtCaret, typeof(Visibility), "AtCursor, AtCaret", Culture)); + } + + [Fact] + public void No_match_hides_the_element() + { + var converter = new EnumToVisibilityConverter(); + + Assert.Equal( + Visibility.Collapsed, + converter.Convert(PopupPlacementMode.FixedPoint, typeof(Visibility), "AtCursor, AtCaret", Culture)); + } + + [Theory] + [InlineData(null, "AtCursor")] + [InlineData(PopupPlacementMode.AtCursor, null)] + [InlineData(PopupPlacementMode.AtCursor, "")] + [InlineData(PopupPlacementMode.AtCursor, ",,")] + [InlineData(null, null)] + public void Without_a_value_or_without_a_list_the_element_is_hidden(object? value, string? parameter) + { + var converter = new EnumToVisibilityConverter(); + + Assert.Equal(Visibility.Collapsed, converter.Convert(value, typeof(Visibility), parameter, Culture)); + } + + [Fact] + public void Extra_spaces_in_the_list_do_not_get_in_the_way() + { + var converter = new EnumToVisibilityConverter(); + + Assert.Equal( + Visibility.Visible, + converter.Convert(ScreenPosition.Center, typeof(Visibility), " Top , Center ", Culture)); + } + + [Fact] + public void Visibility_does_not_convert_back() + { + var converter = new EnumToVisibilityConverter(); + + Assert.Equal( + Binding.DoNothing, + converter.ConvertBack(Visibility.Visible, typeof(PopupPlacementMode), "AtCursor", Culture)); + } + + // The alpha is not shown: transparency is a setting of its own + [Fact] + public void A_colour_is_shown_with_six_digits() + { + var converter = new ColorToHexConverter(); + + Assert.Equal("#0A1B2C", converter.Convert(Color.FromRgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture)); + } + + [Fact] + public void A_semi_transparent_colour_is_shown_without_its_alpha() + { + var converter = new ColorToHexConverter(); + + Assert.Equal( + "#102030", + converter.Convert(Color.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture)); + } + + [Theory] + [InlineData(null)] + [InlineData("not a colour")] + [InlineData(42)] + public void Anything_that_is_not_a_colour_shows_as_an_empty_string(object? value) + { + var converter = new ColorToHexConverter(); + + Assert.Equal(string.Empty, converter.Convert(value, typeof(string), null, Culture)); + } + + [Fact] + public void The_colour_notation_does_not_convert_back() + { + var converter = new ColorToHexConverter(); + + Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(Color), null, Culture)); + } + + [Fact] + public void A_colour_turns_into_a_brush() + { + var converter = new ColorToBrushConverter(); + Color color = Color.FromRgb(0x10, 0x20, 0x30); + + var brush = Assert.IsType(converter.Convert(color, typeof(Brush), null, Culture)); + + Assert.Equal(color, brush.Color); + } + + [Theory] + [InlineData(null)] + [InlineData("not a colour")] + public void Anything_that_is_not_a_colour_turns_into_a_transparent_brush(object? value) + { + var converter = new ColorToBrushConverter(); + + Assert.Same(Brushes.Transparent, converter.Convert(value, typeof(Brush), null, Culture)); + } + + // Picking a swatch in the list sends the colour back into the settings + [Fact] + public void A_brush_converts_back_into_a_colour() + { + var converter = new ColorToBrushConverter(); + Color color = Color.FromRgb(0x10, 0x20, 0x30); + + Assert.Equal(color, converter.ConvertBack(new SolidColorBrush(color), typeof(Color), null, Culture)); + } + + [Theory] + [InlineData(null)] + [InlineData("not a brush")] + public void Anything_that_is_not_a_brush_does_not_convert_back(object? value) + { + var converter = new ColorToBrushConverter(); + + Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(Color), null, Culture)); + } + + [Fact] + public void The_converters_are_fit_for_bindings() + { + Assert.IsAssignableFrom(new EnumToVisibilityConverter()); + Assert.IsAssignableFrom(new ColorToHexConverter()); + Assert.IsAssignableFrom(new ColorToBrushConverter()); + } +} diff --git a/CursorLang.Tests/Views/LayoutPopupWindowTests.cs b/CursorLang.Tests/Views/LayoutPopupWindowTests.cs new file mode 100644 index 0000000..1a4b8ee --- /dev/null +++ b/CursorLang.Tests/Views/LayoutPopupWindowTests.cs @@ -0,0 +1,334 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using CursorLang.Interop; +using CursorLang.Models; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; +using CursorLang.ViewModels; +using CursorLang.Views; + +namespace CursorLang.Tests.Views; + +/// +/// The tooltip window itself: where it ends up and how Windows sees it. +/// +public sealed class LayoutPopupWindowTests +{ + private const int GwlExstyle = -20; + private const int WsExNoactivate = 0x08000000; + private const int WsExToolwindow = 0x00000080; + + [Fact] + public void The_window_is_created_before_the_first_show() + { + Sta.Run(() => + { + using var popup = Popup.Create(new AppSettings()); + + // The handle is needed to set the window bounds before the show: + // otherwise the window flashes at its default size for a moment + Assert.NotEqual(IntPtr.Zero, popup.Handle); + Assert.False(popup.Window.IsVisible); + }); + } + + [Fact] + public void The_window_shows_what_the_view_model_gave_it() + { + Sta.Run(() => + { + var settings = new AppSettings(); + using var popup = Popup.Create(settings); + + Assert.Same(popup.ViewModel, popup.Window.DataContext); + + popup.ViewModel.ShortName = "RU"; + popup.Window.ShowPopup(); + + Assert.True(popup.Window.IsVisible); + }); + } + + [Fact] + public void The_window_does_what_the_popup_service_expects_of_it() + { + Sta.Run(() => + { + using var popup = Popup.Create(new AppSettings()); + + Assert.IsAssignableFrom(popup.Window); + }); + } + + // The tooltip pops up over other applications and must neither take the + // focus nor turn up in Alt+Tab + [Fact] + public void The_window_takes_no_focus_and_stays_out_of_the_switcher() + { + Sta.Run(() => + { + using var popup = Popup.Create(new AppSettings()); + + int style = GetWindowLong(popup.Handle, GwlExstyle); + + Assert.Equal(WsExNoactivate, style & WsExNoactivate); + Assert.Equal(WsExToolwindow, style & WsExToolwindow); + }); + } + + [Fact] + public void The_window_stays_on_top_and_out_of_the_mouses_way() + { + Sta.Run(() => + { + using var popup = Popup.Create(new AppSettings()); + + Assert.True(popup.Window.Topmost); + Assert.False(popup.Window.ShowInTaskbar); + Assert.False(popup.Window.ShowActivated); + Assert.False(popup.Window.IsHitTestVisible); + Assert.False(popup.Window.Focusable); + Assert.Equal(WindowStyle.None, popup.Window.WindowStyle); + Assert.True(popup.Window.AllowsTransparency); + }); + } + + [Fact] + public void The_opacity_comes_from_the_settings() + { + Sta.Run(() => + { + var settings = new AppSettings { Opacity = 0.42 }; + using var popup = Popup.Create(settings); + popup.Window.ShowPopup(); + + Assert.Equal(0.42, popup.Window.Opacity, precision: 3); + + settings.Opacity = 0.75; + Assert.Equal(0.75, popup.Window.Opacity, precision: 3); + }); + } + + [Theory] + [InlineData(ScreenPosition.TopLeft)] + [InlineData(ScreenPosition.Top)] + [InlineData(ScreenPosition.TopRight)] + [InlineData(ScreenPosition.Center)] + [InlineData(ScreenPosition.BottomLeft)] + [InlineData(ScreenPosition.Bottom)] + [InlineData(ScreenPosition.BottomRight)] + public void In_the_fixed_point_mode_the_window_lands_where_it_was_computed(ScreenPosition position) + { + Sta.Run(() => + { + var settings = new AppSettings + { + PlacementMode = PopupPlacementMode.FixedPoint, + ScreenPosition = position, + ScreenMargin = 24, + }; + + using var popup = Popup.Create(settings); + popup.ViewModel.ShortName = "RU"; + + (PopupWindowNative.Rect before, _) = PopupWindowNative.GetActiveMonitorWorkArea(); + popup.Window.ShowPopup(); + (PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea(); + + if (!before.Equals(work)) + { + Assert.Skip("The active monitor changed while the check was running"); + } + + PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value; + + int width = bounds.Right - bounds.Left; + int height = bounds.Bottom - bounds.Top; + PopupWindowNative.Point expected = PopupLayout.OnScreen( + work, position, PopupLayout.ToPixels(settings.ScreenMargin, scale), width, height); + + Assert.Equal(expected.X, bounds.Left); + Assert.Equal(expected.Y, bounds.Top); + }); + } + + [Fact] + public void At_the_cursor_the_window_lands_next_to_it() + { + Sta.Run(() => + { + var settings = new AppSettings + { + PlacementMode = PopupPlacementMode.AtCursor, + CursorSide = AnchorSide.BottomRight, + CursorOffset = 16, + }; + + using var popup = Popup.Create(settings); + popup.ViewModel.ShortName = "RU"; + + // The place is computed from where the cursor was at the moment of + // the show. If it was moving right then, show it once more + PopupWindowNative.Point before = default; + + for (int attempt = 0; attempt < 10; attempt++) + { + before = PopupWindowNative.GetCursorPosition(); + popup.Window.ShowPopup(); + PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition(); + + if (before.X == after.X && before.Y == after.Y) + { + break; + } + + if (attempt == 9) + { + Assert.Skip("The cursor kept moving the whole time"); + } + } + + PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value; + double scale = PopupWindowNative.GetScaleAt(before); + + PopupWindowNative.Point expected = PopupLayout.NearAnchor( + PopupLayout.AsAnchor(before), + AnchorSide.BottomRight, + PopupLayout.ToPixels(settings.CursorOffset, scale), + bounds.Right - bounds.Left, + bounds.Bottom - bounds.Top); + + Assert.Equal(expected.X, bounds.Left); + Assert.Equal(expected.Y, bounds.Top); + }); + } + + // There is no caret in the test environment, and the tooltip has to fall + // back to the cursor + [Fact] + public void Without_a_caret_the_window_lands_at_the_cursor() + { + Sta.Run(() => + { + var settings = new AppSettings + { + PlacementMode = PopupPlacementMode.AtCaret, + CaretSide = AnchorSide.BottomRight, + CaretOffset = 8, + }; + + using var popup = Popup.Create(settings); + popup.ViewModel.ShortName = "RU"; + + PopupWindowNative.Point before = PopupWindowNative.GetCursorPosition(); + popup.Window.ShowPopup(); + + PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value; + + // A gentle check: a window holding the input focus may still have a caret + Assert.True(bounds.Right > bounds.Left); + Assert.True(bounds.Bottom > bounds.Top); + Assert.NotEqual(default, before); + }); + } + + // The window size equals the size of the text: the tooltip has no frame + [Fact] + public void The_window_size_follows_the_size_of_the_caption() + { + Sta.Run(() => + { + var small = new AppSettings { FontSize = 12, PlacementMode = PopupPlacementMode.FixedPoint }; + var large = new AppSettings { FontSize = 48, PlacementMode = PopupPlacementMode.FixedPoint }; + + using var smallPopup = Popup.Create(small); + using var largePopup = Popup.Create(large); + + smallPopup.ViewModel.ShortName = "RU"; + largePopup.ViewModel.ShortName = "RU"; + + smallPopup.Window.ShowPopup(); + largePopup.Window.ShowPopup(); + + PopupWindowNative.Rect smallBounds = WindowPlacementNative.TryGetBounds(smallPopup.Handle)!.Value; + PopupWindowNative.Rect largeBounds = WindowPlacementNative.TryGetBounds(largePopup.Handle)!.Value; + + Assert.True(largeBounds.Right - largeBounds.Left > smallBounds.Right - smallBounds.Left); + Assert.True(largeBounds.Bottom - largeBounds.Top > smallBounds.Bottom - smallBounds.Top); + }); + } + + [Fact] + public void Showing_again_moves_the_window_to_its_new_place() + { + Sta.Run(() => + { + var settings = new AppSettings + { + PlacementMode = PopupPlacementMode.FixedPoint, + ScreenPosition = ScreenPosition.TopLeft, + }; + + using var popup = Popup.Create(settings); + popup.ViewModel.ShortName = "RU"; + popup.Window.ShowPopup(); + + PopupWindowNative.Rect topLeft = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value; + + settings.ScreenPosition = ScreenPosition.BottomRight; + popup.Window.ShowPopup(); + + PopupWindowNative.Rect bottomRight = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value; + + Assert.True(bottomRight.Left > topLeft.Left); + Assert.True(bottomRight.Top > topLeft.Top); + }); + } + + [Fact] + public void A_hidden_window_stays_alive() + { + Sta.Run(() => + { + using var popup = Popup.Create(new AppSettings()); + + popup.Window.ShowPopup(); + popup.Window.Hide(); + + Assert.False(popup.Window.IsVisible); + Assert.NotEqual(IntPtr.Zero, popup.Handle); + + popup.Window.ShowPopup(); + Assert.True(popup.Window.IsVisible); + }); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + /// The tooltip window together with everything it needs to work. + private sealed class Popup : IDisposable + { + private Popup(LayoutPopupWindow window, LayoutPopupViewModel viewModel) + { + Window = window; + ViewModel = viewModel; + Handle = new WindowInteropHelper(window).Handle; + } + + internal LayoutPopupWindow Window { get; } + + internal LayoutPopupViewModel ViewModel { get; } + + internal IntPtr Handle { get; } + + internal static Popup Create(AppSettings settings) + { + var viewModel = new LayoutPopupViewModel(settings); + return new Popup(new LayoutPopupWindow(viewModel, settings), viewModel); + } + + public void Dispose() => Window.Close(); + } +} diff --git a/CursorLang.Tests/Views/MainWindowTests.cs b/CursorLang.Tests/Views/MainWindowTests.cs new file mode 100644 index 0000000..30d6f41 --- /dev/null +++ b/CursorLang.Tests/Views/MainWindowTests.cs @@ -0,0 +1,365 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; +using CursorLang.Models; +using CursorLang.Services; +using CursorLang.Tests.Infrastructure; +using CursorLang.ViewModels; +using CursorLang.Views; + +namespace CursorLang.Tests.Views; + +/// +/// The settings window as a whole: the markup, the bindings and the hook-up +/// to the theme. +/// +/// +/// The window has to be shown for real: before the show WPF builds no element +/// tree and computes no bindings. Full transparency keeps it out of sight. +/// +public sealed class MainWindowTests +{ + [Fact] + public void The_window_is_built_and_takes_its_data_from_the_view_model() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + var theme = new FakeThemeService(); + + Open(viewModel, theme, window => + { + Assert.Same(viewModel, window.DataContext); + Assert.Equal([window], theme.Registered); + }); + }); + } + + [Fact] + public void The_window_title_comes_from_the_interface_strings() + { + Sta.Run(() => + { + var localization = new FakeLocalizationService(); + using SettingsViewModel viewModel = CreateViewModel(localization: localization); + + Open(viewModel, window => + { + Assert.Equal("en:SettingsTitle", window.Title); + + // A language change goes over every binding to a string + localization.CurrentLanguage = "ru"; + Assert.Equal("ru:SettingsTitle", window.Title); + }); + }); + } + + [Fact] + public void The_window_fits_its_height_to_its_content() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + + Open(viewModel, window => + { + Assert.Equal(SizeToContent.Height, window.SizeToContent); + Assert.Equal(ResizeMode.CanMinimize, window.ResizeMode); + Assert.True(window.ActualHeight > 0); + Assert.True(window.ActualWidth > 0); + }); + }); + } + + // The markup asks the view model for lists and palettes: if a name drifts + // apart from the model, the binding silently shows an empty list + [Fact] + public void The_lists_in_the_window_are_filled() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + + Open(viewModel, window => + { + List boxes = [.. FindAll(window)]; + + Assert.NotEmpty(boxes); + Assert.All(boxes, box => Assert.NotEmpty(box.Items)); + }); + }); + } + + [Fact] + public void The_lists_show_captions_in_the_chosen_language() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + + Open(viewModel, window => + { + // Languages are named in themselves, while the enum options are + // named by strings from the resources: the latter are checked + List displays = + [ + .. FindAll(window) + .SelectMany(box => box.Items.OfType()) + .Where(item => item.GetType().Name.StartsWith("EnumOption", StringComparison.Ordinal)) + .Select(item => item.ToString() ?? string.Empty), + ]; + + Assert.NotEmpty(displays); + Assert.All(displays, display => Assert.StartsWith("en:", display, StringComparison.Ordinal)); + }); + }); + } + + [Fact] + public void The_window_shows_a_preview_of_the_tooltip() + { + Sta.Run(() => + { + var settings = new AppSettings { FontSize = 33 }; + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + // The font size from the settings is visible right in the window + Assert.Contains(FindAll(window), text => Math.Abs(text.FontSize - 33) < 0.001)); + }); + } + + [Fact] + public void The_tooltip_colours_are_shown_as_swatches() + { + Sta.Run(() => + { + var settings = new AppSettings(); + using SettingsViewModel viewModel = CreateViewModel(settings); + + // The chosen colour is shown as a swatch with a caption — in the + // same notation the settings file uses + Color chosen = viewModel.BackgroundPalette[2]; + settings.BackgroundColor = chosen; + + string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}"; + + Open(viewModel, window => Assert.Contains( + FindAll(window), + text => text.Text.Equals(expected, StringComparison.OrdinalIgnoreCase))); + }); + } + + [Fact] + public void The_startup_setting_hides_until_Windows_answers() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + + Open(viewModel, window => + { + Assert.False(viewModel.IsStartupAvailable); + Assert.All(FindStartupCheckBoxes(window), box => Assert.False(box.IsVisible)); + }); + }); + } + + [Fact] + public void An_allowed_startup_shows_up_in_the_window() + { + Sta.Run(() => + { + var startup = new FakeStartupService { State = StartupState.Disabled }; + using SettingsViewModel viewModel = CreateViewModel(startup: startup); + + Open(viewModel, window => + { + viewModel.InitializeAsync().GetAwaiter().GetResult(); + window.UpdateLayout(); + + CheckBox box = Assert.Single(FindStartupCheckBoxes(window)); + Assert.True(box.IsVisible); + Assert.True(box.IsEnabled); + Assert.False(box.IsChecked); + }); + }); + } + + // A ban by the user is not for the app to argue with: the tick is shown, + // but it cannot be moved + [Fact] + public void A_startup_banned_by_Windows_is_shown_as_unavailable() + { + Sta.Run(() => + { + var startup = new FakeStartupService { State = StartupState.DisabledByUser }; + using SettingsViewModel viewModel = CreateViewModel(startup: startup); + + Open(viewModel, window => + { + viewModel.InitializeAsync().GetAwaiter().GetResult(); + window.UpdateLayout(); + + CheckBox box = Assert.Single(FindStartupCheckBoxes(window)); + Assert.True(box.IsVisible); + Assert.False(box.IsEnabled); + }); + }); + } + + [Fact] + public void The_Caps_Lock_interception_is_toggled_by_a_tick() + { + Sta.Run(() => + { + var settings = new AppSettings { UseCapsLockHotkey = false }; + using SettingsViewModel viewModel = CreateViewModel(settings); + + Open(viewModel, window => + { + CheckBox box = Assert.Single(FindCheckBoxesBoundTo(window, "Settings.UseCapsLockHotkey")); + + box.IsChecked = true; + + Assert.True(settings.UseCapsLockHotkey); + }); + }); + } + + [Fact] + public void The_updates_are_checked_by_the_button_in_the_window() + { + Sta.Run(() => + { + var updates = new FakeUpdateService(); + using UpdateViewModel section = CreateUpdates(updates); + using SettingsViewModel viewModel = CreateViewModel(updates: section); + + Open(viewModel, window => + { + Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand")); + + Assert.True(check.IsVisible); + check.Command.Execute(null); + + Assert.Equal(1, updates.CheckCalls); + }); + }); + } + + // An app installed from the Store is updated by the Store + [Fact] + public void An_app_that_updates_itself_elsewhere_shows_no_updates_section() + { + Sta.Run(() => + { + using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false }); + using SettingsViewModel viewModel = CreateViewModel(updates: section); + + Open(viewModel, window => + Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button => + Assert.False(button.IsVisible))); + }); + } + + [Fact] + public void The_window_hooks_up_to_the_placement() + { + Sta.Run(() => + { + using SettingsViewModel viewModel = CreateViewModel(); + var placement = new MainWindowPlacement(); + + Open(viewModel, new FakeThemeService(), placement, window => + { + // The placement works off the window creation event: the window + // has to end up on a monitor rather than beyond its edges + Assert.True(window.Left > -10_000); + Assert.True(window.Top > -10_000); + }); + }); + } + + private static SettingsViewModel CreateViewModel( + AppSettings? settings = null, + ILocalizationService? localization = null, + IStartupService? startup = null, + UpdateViewModel? updates = null) => + new(settings ?? new AppSettings(), + localization ?? new FakeLocalizationService(), + startup ?? new FakeStartupService(), + updates ?? Fake.Updates()); + + private static void Open(SettingsViewModel viewModel, Action check) => + Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check); + + private static void Open(SettingsViewModel viewModel, IThemeService theme, Action check) => + Open(viewModel, theme, new MainWindowPlacement(), check); + + private static void Open( + SettingsViewModel viewModel, + IThemeService theme, + MainWindowPlacement placement, + Action check) + { + var window = new MainWindow(viewModel, theme, placement) + { + // The window is needed alive, but not in sight + Opacity = 0, + ShowInTaskbar = false, + ShowActivated = false, + }; + + try + { + window.Show(); + window.UpdateLayout(); + + check(window); + } + finally + { + window.Close(); + } + } + + private static UpdateViewModel CreateUpdates(IUpdateService updates) => + new(updates, new FakeLocalizationService(), new AppSettings(), new UpdateOptions()); + + private static IEnumerable