added tests to project

This commit is contained in:
2026-08-09 18:31:43 +05:00
parent 6da32504f9
commit d5dc228ebe
39 changed files with 6491 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.ViewModels;
using CursorLang.Views;
using Microsoft.Extensions.DependencyInjection;
namespace CursorLang.Tests;
/// <summary>
/// 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.
/// </summary>
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());
}
/// <summary>
/// The dependencies of every service have to be resolvable. The check runs
/// while the container is built and creates no services itself.
/// </summary>
[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;
}
}
+61
View File
@@ -0,0 +1,61 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<OutputType>Exe</OutputType>
<PlatformTarget>AnyCPU</PlatformTarget>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<NoWarn>$(NoWarn);IDE0130</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="coverlet.collector" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang\CursorLang.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CursorLangExecutable</_Parameter1>
<_Parameter2>$(MSBuildThisFileDirectory)..\CursorLang\bin\$(Configuration)\$(TargetFramework)\CursorLang.exe</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<PropertyGroup>
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang\Views\MainWindow.xaml</MainWindowMarkup>
</PropertyGroup>
<Target Name="EmbedMainWindowMarkup" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<MainWindowMarkupCopy>$(IntermediateOutputPath)MainWindow.xaml.txt</MainWindowMarkupCopy>
</PropertyGroup>
<Copy SourceFiles="$(MainWindowMarkup)"
DestinationFiles="$(MainWindowMarkupCopy)"
SkipUnchangedFiles="true" />
<ItemGroup>
<EmbeddedResource Include="$(MainWindowMarkupCopy)"
LogicalName="MainWindow.xaml" />
<FileWrites Include="$(MainWindowMarkupCopy)" />
</ItemGroup>
</Target>
</Project>
+153
View File
@@ -0,0 +1,153 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
namespace CursorLang.Tests;
/// <summary>
/// The application as a whole: the start, the single instance and the exit.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable
{
private Launch(Process process) => Process = process;
internal Process Process { get; }
/// <summary>Starts the application first — making sure the place is free.</summary>
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());
}
/// <summary>Starts the application the way the user does.</summary>
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 })!;
}
/// <summary>
/// Waits for the settings window: by the time it appears the application
/// has raised its whole cast.
/// </summary>
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<AssemblyMetadataAttribute>()
.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();
}
}
}
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http;
using System.Text;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// The network as the test writes it: the answer is decided here rather than
/// by a repository somewhere.
/// </summary>
internal sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _reply;
internal FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> reply) => _reply = reply;
internal List<HttpRequestMessage> 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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(_reply(request));
}
}
+289
View File
@@ -0,0 +1,289 @@
using System.ComponentModel;
using System.Net.Http;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.ViewModels;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// Parts every test needs but few tests care about.
/// </summary>
internal static class Fake
{
internal static UpdateViewModel Updates() =>
new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
}
/// <summary>
/// The keyboard layout, with the test in charge of it.
/// </summary>
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<LayoutChangedEventArgs>? 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;
}
/// <summary>
/// A tooltip that pops up nowhere and merely remembers what it was asked for.
/// </summary>
internal sealed class FakeLayoutPopupService : ILayoutPopupService
{
internal List<KeyboardLayout> Shown { get; } = [];
internal List<KeyboardLayout> 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++;
}
/// <summary>
/// The Caps Lock interception without intercepting anything: the test supplies the presses.
/// </summary>
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;
}
/// <summary>
/// Startup whose state the test assigns.
/// </summary>
internal sealed class FakeStartupService : IStartupService
{
internal StartupState State { get; set; } = StartupState.Disabled;
internal StartupState? AnswerOnEnable { get; set; }
internal List<bool> Requests { get; } = [];
internal int GetStateCalls { get; private set; }
public Task<StartupState> GetStateAsync()
{
GetStateCalls++;
return Task.FromResult(State);
}
public Task<StartupState> SetEnabledAsync(bool enabled)
{
Requests.Add(enabled);
State = enabled
? AnswerOnEnable ?? StartupState.Enabled
: StartupState.Disabled;
return Task.FromResult(State);
}
}
/// <summary>
/// Releases the test writes itself, with no repository behind them.
/// </summary>
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<string> Installed { get; } = [];
public bool IsSupported { get; set; } = true;
public Version CurrentVersion { get; set; } = new(1, 0, 0, 0);
public Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
{
CheckCalls++;
return Failure is null
? Task.FromResult(Release)
: Task.FromException<ReleaseInfo?>(Failure);
}
public async Task<string> DownloadAsync(
ReleaseInfo release,
IProgress<double>? 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);
}
/// <summary>
/// A release list the test fills in, with no repository behind it.
/// </summary>
internal sealed class FakeReleaseFeed : IReleaseFeed
{
internal ReleaseInfo? Release { get; set; }
internal Exception? Failure { get; set; }
internal List<HttpRequestMessage> Authorized { get; } = [];
public Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken) =>
Failure is null ? Task.FromResult(Release) : Task.FromException<ReleaseInfo?>(Failure);
public void Authorize(HttpRequestMessage request) => Authorized.Add(request);
}
/// <summary>
/// Interface strings without resources: the key comes back as is, tagged with the language.
/// </summary>
internal sealed class FakeLocalizationService : ILocalizationService
{
private string _currentLanguage = "en";
public event PropertyChangedEventHandler? PropertyChanged;
internal List<string> RequestedKeys { get; } = [];
public string this[string key]
{
get
{
RequestedKeys.Add(key);
return $"{_currentLanguage}:{key}";
}
}
public IReadOnlyList<LanguageOption> 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));
}
}
}
/// <summary>
/// A theme that paints nothing and only remembers the windows attached to it.
/// </summary>
internal sealed class FakeThemeService : IThemeService
{
public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
internal List<System.Windows.Window> Registered { get; } = [];
public void Register(System.Windows.Window window) => Registered.Add(window);
}
/// <summary>
/// A tooltip window that shows nothing.
/// </summary>
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<string> Calls { get; } = [];
public void ShowPopup()
{
ShowCalls++;
Calls.Add("show");
}
public void Hide()
{
HideCalls++;
Calls.Add("hide");
}
public void Close()
{
CloseCalls++;
Calls.Add("close");
}
}
+179
View File
@@ -0,0 +1,179 @@
using System.Windows;
using System.Windows.Threading;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// The user interface thread for the tests.
/// </summary>
/// <remarks>
/// 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 <see cref="Application"/>, 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 <see cref="WaitFor"/>.
/// </remarks>
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();
}
}
}
/// <summary>Runs an action on the interface thread and waits for it to finish.</summary>
internal static void Run(Action action) => Dispatcher.Invoke(action);
/// <summary>The same for an action that returns a result.</summary>
internal static TResult Run<TResult>(Func<TResult> action) => Dispatcher.Invoke(action);
/// <summary>
/// Runs an action on a separate STA thread and waits for it to finish.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
/// <summary>
/// Waits until the dispatcher queue drains: calls deferred through
/// <c>BeginInvoke</c> have run by that time.
/// </summary>
internal static void Drain() =>
Dispatcher.Invoke(static () => { }, DispatcherPriority.ApplicationIdle);
/// <summary>
/// Waits for a condition without getting in the way of the timers.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static void WaitFor(Func<bool> 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));
}
}
/// <summary>
/// Waits for the given time while still pumping the queue: that is how
/// "nothing happened during this time" is verified.
/// </summary>
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<Dispatcher>();
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();
}
}
@@ -0,0 +1,39 @@
using System.IO;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// 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.
/// </summary>
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
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.Win32;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// 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.
/// </summary>
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
}
}
}
@@ -0,0 +1,106 @@
using CursorLang.Interop;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Interop;
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
@@ -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;
/// <summary>
/// Checks that need a real foreground window with an input field: the caret
/// and the layout switch live exactly there.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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));
}
});
}
/// <summary>A window with an input field brought to the foreground.</summary>
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; }
/// <summary>Skips the check if the window never became the foreground one.</summary>
internal void RequireForeground()
{
if (KeyboardLayoutNative.GetForegroundWindow() != Handle)
{
Assert.Skip("The window could not be brought to the foreground");
}
}
public void Dispose() => _window.Close();
}
}
@@ -0,0 +1,209 @@
using System.Reflection;
using System.Runtime.InteropServices;
using CursorLang.Interop;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Interop;
/// <summary>
/// Making sense of the events of the system keyboard hook.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<int> 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<int> 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<int> 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<int> 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);
}
}
}
@@ -0,0 +1,243 @@
using System.Windows;
using System.Windows.Interop;
using CursorLang.Interop;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Interop;
/// <summary>
/// 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.
/// </summary>
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<ForegroundInputNative.GuiThreadInfo>(),
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);
}
/// <summary>A window with a created handle that never appears on screen.</summary>
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();
}
}
+165
View File
@@ -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<string?> 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<string?> 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<string> 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<INotifyPropertyChanged>(new AppSettings());
}
public static TheoryData<string> WritableProperties()
{
var data = new TheoryData<string>();
foreach (string name in WritablePropertyNames())
{
data.Add(name);
}
return data;
}
/// <summary>Names of the settings the user is able to change.</summary>
internal static IEnumerable<string> 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");
}
}
@@ -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");
}
}
@@ -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<EventArgs>(args);
}
}
+178
View File
@@ -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;
/// <summary>
/// Checks of the resources themselves: they carry every caption in the settings
/// window, and a missing key only shows on a live window.
/// </summary>
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<string> 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<string> neutral = [.. NeutralKeys()];
List<string> 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);
}
}
/// <summary>
/// Every key the settings window markup asks for has to exist in the
/// resources: otherwise the user sees the key itself in its place.
/// </summary>
[Fact]
public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
{
HashSet<string> known = [.. NeutralKeys()];
List<string> missing = [];
foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
{
string key = match.Groups["key"].Value;
if (!known.Contains(key))
{
missing.Add(key);
}
}
Assert.Empty(missing);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>The markup does ask for strings — otherwise the check above means nothing.</summary>
[Fact]
public void The_settings_window_markup_asks_for_resource_strings()
{
Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
}
public static TheoryData<string> EnumKeys()
{
var data = new TheoryData<string>();
foreach (string key in EnumKeysOf<AppTheme>())
{
data.Add(key);
}
foreach (string key in EnumKeysOf<PopupPlacementMode>())
{
data.Add(key);
}
foreach (string key in EnumKeysOf<AnchorSide>())
{
data.Add(key);
}
foreach (string key in EnumKeysOf<ScreenPosition>())
{
data.Add(key);
}
return data;
}
// A caption key is built from the type name and the value: PopupPlacementMode_AtCursor
private static IEnumerable<string> EnumKeysOf<TEnum>() where TEnum : struct, Enum =>
Enum.GetValues<TEnum>().Select(value => $"{typeof(TEnum).Name}_{value}");
private static IEnumerable<string> 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\[(?<key>\w+)\]")]
private static partial Regex LocalizationBinding();
}
@@ -0,0 +1,274 @@
using System.Collections.Concurrent;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// Making sense of Caps Lock presses: a short one differs from a long one only
/// by when the key was released.
/// </summary>
public sealed class CapsLockHotkeyServiceTests
{
private const int CapsLock = 0x14;
private const int LetterA = 0x41;
[Fact]
public void A_Caps_Lock_press_is_not_passed_on()
{
using var harness = Harness.Create(holdMilliseconds: 10_000);
Assert.True(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);
});
}
/// <summary>
/// The service together with its settings and the list of events that happened.
/// </summary>
private sealed class Harness : IDisposable
{
private Harness(CapsLockHotkeyService service, AppSettings settings)
{
Service = service;
Settings = settings;
}
internal CapsLockHotkeyService Service { get; }
internal AppSettings Settings { get; }
/// <summary>Events arrive from the interface thread and are read by the test thread.</summary>
internal ConcurrentQueue<string> Events { get; } = new();
internal static Harness Create(double holdMilliseconds)
{
var settings = new AppSettings { CapsLockHoldMilliseconds = holdMilliseconds };
// 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);
}
}
@@ -0,0 +1,192 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// What happens on Caps Lock presses and how the interception follows the setting.
/// </summary>
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);
}
@@ -0,0 +1,288 @@
using System.Collections.Concurrent;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// The state of the system as the service sees it, and everything the
/// service reported about it.
/// </summary>
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<LayoutChangedEventArgs> 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);
}
}
}
}
@@ -0,0 +1,116 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// The link between watching the layout and showing the tooltip.
/// </summary>
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);
}
}
@@ -0,0 +1,212 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using CursorLang.ViewModels;
namespace CursorLang.Tests.Services;
/// <summary>
/// The lifetime of the tooltip. Its timer lives on the interface thread,
/// so everything happens there as well.
/// </summary>
public sealed class LayoutPopupServiceTests
{
private static readonly KeyboardLayout Russian = KeyboardLayout.FromLocaleId(0x0419);
private static readonly KeyboardLayout English = KeyboardLayout.FromLocaleId(0x0409);
[Fact]
public void Showing_puts_out_the_short_name_of_the_layout()
{
var window = new FakeLayoutPopupWindow();
var settings = new AppSettings { DurationMilliseconds = 10_000 };
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);
});
}
}
@@ -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<string?> 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<string?> 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<string?> 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<LanguageOption> 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<LanguageOption> 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<INotifyPropertyChanged>(new LocalizationService());
}
}
@@ -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;
/// <summary>
/// Placing the settings window: the centre maths and bringing the window back
/// into the work area.
/// </summary>
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);
});
}
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Reports a move the way WPF does after the user acts.</summary>
internal void RaiseLocationChanged() => OnLocationChanged(EventArgs.Empty);
public void Dispose() => Close();
}
}
@@ -0,0 +1,229 @@
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Services;
namespace CursorLang.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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<ScreenPosition, int, int> 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));
}
}
@@ -0,0 +1,156 @@
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
using Microsoft.Win32;
namespace CursorLang.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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<byte>(), 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;
}
/// <summary>The mark Windows leaves after the user switches the entry off.</summary>
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);
}
}
@@ -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;
/// <summary>
/// Keeping the settings in a file. Everything happens in a temporary folder:
/// the tests have no business touching the user's own settings.
/// </summary>
public sealed class SettingsServiceTests
{
/// <summary>The deferred write delay in tests: half a second is not worth waiting for.</summary>
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<string> 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);
}
@@ -0,0 +1,204 @@
using System.Collections.Concurrent;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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<EventArgs> 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<EventArgs> 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<EventArgs> 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");
/// <summary>
/// Tries to take the place the way a run started afterwards does it —
/// from another thread rather than from the same one.
/// </summary>
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;
}
}
@@ -0,0 +1,55 @@
using Windows.ApplicationModel;
using CursorLang.Interop;
using CursorLang.Models;
using CursorLang.Services;
namespace CursorLang.Tests.Services;
/// <summary>
/// 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.
/// </summary>
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<StartupTaskState>())
{
Assert.NotEqual(StartupState.Unavailable, StartupService.Translate(state));
}
}
}
@@ -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;
/// <summary>
/// The look of the windows. The palette lives in the application resources,
/// so everything happens on the interface thread.
/// </summary>
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<object>().OrderBy(key => key.ToString()),
dark.Keys.Cast<object>().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<Color> 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;
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<Color> PaintOf(DependencyObject root)
{
var colours = new List<Color>();
Collect(root, colours);
return colours;
}
private static void Collect(DependencyObject node, List<Color> 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<Color> colours, params Brush?[] brushes) =>
colours.AddRange(brushes.OfType<SolidColorBrush>().Select(brush => brush.Color));
}
@@ -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>(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>(AppTheme.Dark, "Dark");
List<string?> 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>(AppTheme.Dark, "Dark theme");
List<string?> 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>(AppTheme.Dark, "Dark theme").ToString());
}
[Fact]
public void A_changed_caption_changes_the_presentation_too()
{
var option = new EnumOption<ScreenPosition>(ScreenPosition.Center, "Center") { Display = "In the centre" };
Assert.Equal("In the centre", option.ToString());
}
}
@@ -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<string?> 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<string?> changed = [];
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
viewModel.ShortName = "RU";
Assert.Empty(changed);
}
}
@@ -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;
/// <summary>
/// The settings window in terms of what it shows and what it is in charge of.
/// </summary>
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<AppTheme>(), viewModel.Themes.Select(option => option.Value));
Assert.Equal(Enum.GetValues<PopupPlacementMode>(), viewModel.PlacementModes.Select(option => option.Value));
Assert.Equal(Enum.GetValues<AnchorSide>(), viewModel.AnchorSides.Select(option => option.Value));
Assert.Equal(Enum.GetValues<ScreenPosition>(), 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<AppTheme> 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>(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<string?> 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<string?> 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<AppTheme> 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);
}
}
+168
View File
@@ -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;
/// <summary>
/// The binding converters: they decide what the settings window shows and what
/// it keeps out of sight.
/// </summary>
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<SolidColorBrush>(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<IValueConverter>(new EnumToVisibilityConverter());
Assert.IsAssignableFrom<IValueConverter>(new ColorToHexConverter());
Assert.IsAssignableFrom<IValueConverter>(new ColorToBrushConverter());
}
}
@@ -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;
/// <summary>
/// The tooltip window itself: where it ends up and how Windows sees it.
/// </summary>
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<ILayoutPopupWindow>(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);
/// <summary>The tooltip window together with everything it needs to work.</summary>
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();
}
}
+365
View File
@@ -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;
/// <summary>
/// The settings window as a whole: the markup, the bindings and the hook-up
/// to the theme.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<ComboBox> boxes = [.. FindAll<ComboBox>(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<string> displays =
[
.. FindAll<ComboBox>(window)
.SelectMany(box => box.Items.OfType<object>())
.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<TextBlock>(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<TextBlock>(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<MainWindow> check) =>
Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check);
private static void Open(SettingsViewModel viewModel, IThemeService theme, Action<MainWindow> check) =>
Open(viewModel, theme, new MainWindowPlacement(), check);
private static void Open(
SettingsViewModel viewModel,
IThemeService theme,
MainWindowPlacement placement,
Action<MainWindow> 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<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
FindAll<Button>(root).Where(button =>
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
private static IEnumerable<CheckBox> FindStartupCheckBoxes(DependencyObject root) =>
FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup));
// An element is found by what it is bound to: the markup gives them no names
private static IEnumerable<CheckBox> FindCheckBoxesBoundTo(DependencyObject root, string path) =>
FindAll<CheckBox>(root).Where(box =>
BindingOperations.GetBinding(box, ToggleButton.IsCheckedProperty)?.Path.Path == path);
// Walking the element tree: the window markup is large, and things have to be searched for
private static IEnumerable<TElement> FindAll<TElement>(DependencyObject root)
where TElement : DependencyObject
{
int count = VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < count; i++)
{
DependencyObject child = VisualTreeHelper.GetChild(root, i);
if (child is TElement found)
{
yield return found;
}
foreach (TElement nested in FindAll<TElement>(child))
{
yield return nested;
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<RunConfiguration>
<ResultsDirectory>..\TestResults</ResultsDirectory>
</RunConfiguration>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<!-- Coverage is measured for the application alone -->
<Include>[CursorLang]*</Include>
<Format>cobertura</Format>
<SingleHit>false</SingleHit>
<UseSourceLink>false</UseSourceLink>
<IncludeTestAssembly>false</IncludeTestAssembly>
<ExcludeByFile>**/*.g.cs,**/*.g.i.cs</ExcludeByFile>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"methodDisplay": "method",
"parallelizeTestCollections": false
}
+29
View File
@@ -2,15 +2,44 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang", "CursorLang\CursorLang.csproj", "{4729F06C-53D8-4871-8750-A0632F0A6B07}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CursorLang.Tests", "CursorLang.Tests\CursorLang.Tests.csproj", "{558F7646-AC3F-4E5E-853D-9F2A09E89C06}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x64.ActiveCfg = Debug|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x64.Build.0 = Debug|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x86.ActiveCfg = Debug|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Debug|x86.Build.0 = Debug|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|Any CPU.Build.0 = Release|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x64.ActiveCfg = Release|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x64.Build.0 = Release|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x86.ActiveCfg = Release|Any CPU
{4729F06C-53D8-4871-8750-A0632F0A6B07}.Release|x86.Build.0 = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|Any CPU.Build.0 = Debug|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x64.ActiveCfg = Debug|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x64.Build.0 = Debug|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x86.ActiveCfg = Debug|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Debug|x86.Build.0 = Debug|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|Any CPU.ActiveCfg = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|Any CPU.Build.0 = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x64.ActiveCfg = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x64.Build.0 = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.ActiveCfg = Release|Any CPU
{558F7646-AC3F-4E5E-853D-9F2A09E89C06}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal