added tests to project
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user