lightweight variant (#1)

Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
@@ -0,0 +1,192 @@
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.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,304 @@
using System.Net;
using System.Runtime.InteropServices;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Reading the release list of Gitea. The answer of the server is not ours to
/// shape, so what matters is what the app makes of it.
/// </summary>
public sealed class GiteaReleaseFeedTests
{
private const string Releases = """
[
{
"tag_name": "v1.2.0",
"draft": false,
"prerelease": false,
"html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0",
"assets": [
{
"name": "CursorLang-1.2.0.0.msixbundle",
"browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
"size": 4096
}
]
}
]
""";
[Fact]
public async Task A_release_is_read_whole()
{
ReleaseInfo? release = await Read(Releases);
Assert.NotNull(release);
Assert.Equal(new Version(1, 2, 0, 0), release.Version);
Assert.Equal("v1.2.0", release.Tag);
Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString());
Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName);
Assert.Equal(
"https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
release.Package.Url.ToString());
Assert.Equal(4096, release.Package.Size);
}
// The API of Gitea lives on the server itself, next to the pages of the
// repository
[Fact]
public async Task The_request_goes_to_the_releases_of_the_project()
{
var handler = FakeHttpHandler.Json(Releases);
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
Uri asked = Assert.Single(handler.Requests).RequestUri!;
Assert.Equal("git.alrakis.kz", asked.Host);
Assert.StartsWith(
"/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal);
}
// A server sitting under a path of its own keeps that path: dropping it
// would send the request to a place that answers nothing
[Fact]
public async Task A_server_behind_a_path_keeps_it()
{
var handler = FakeHttpHandler.Json(Releases);
var feed = new GiteaReleaseFeed(
handler.CreateClient(),
new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" });
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
Uri asked = Assert.Single(handler.Requests).RequestUri!;
Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal);
}
// «token» is the scheme of Gitea for keys of access
[Fact]
public void A_closed_repository_gets_the_token_it_asks_for()
{
var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret"));
using var request = new HttpRequestMessage();
feed.Authorize(request);
Assert.Equal("token", request.Headers.Authorization?.Scheme);
Assert.Equal("secret", request.Headers.Authorization?.Parameter);
}
[Fact]
public void An_open_repository_is_asked_without_a_token()
{
var feed = new GiteaReleaseFeed(new HttpClient(), Options());
using var request = new HttpRequestMessage();
feed.Authorize(request);
Assert.Null(request.Headers.Authorization);
}
[Theory]
[InlineData("1.2.3", "1.2.3.0")]
[InlineData("v1.2.3", "1.2.3.0")]
[InlineData("V1.2", "1.2.0.0")]
[InlineData("1.2.3.4", "1.2.3.4")]
public async Task A_version_is_read_out_of_the_tag(string tag, string expected)
{
ReleaseInfo? release = await Read(WithTag(tag));
Assert.NotNull(release);
Assert.Equal(Version.Parse(expected), release.Version);
}
// A pre-release version is not something the app offers by itself:
// such a version is asked for on purpose
[Theory]
[InlineData("v1.2.3-beta")]
[InlineData("nightly")]
[InlineData("release-1")]
public async Task A_tag_that_is_not_a_version_is_passed_over(string tag)
{
Assert.Null(await Read(WithTag(tag)));
}
[Fact]
public async Task A_draft_and_a_pre_release_are_passed_over()
{
const string releases = """
[
{ "tag_name": "v3.0.0", "draft": true, "assets": [
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
{ "tag_name": "v2.0.0", "prerelease": true, "assets": [
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] },
{ "tag_name": "v1.0.0", "assets": [
{ "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
}
// The order of the releases belongs to the server, the highest number to
// the app: a fix to an older branch can be the freshest release
[Fact]
public async Task The_highest_version_wins_over_the_order_of_the_answer()
{
const string releases = """
[
{ "tag_name": "v1.0.5", "assets": [
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
{ "tag_name": "v2.0.0", "assets": [
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(new Version(2, 0, 0, 0), release.Version);
}
[Fact]
public async Task A_release_without_a_package_is_passed_over()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] },
{ "tag_name": "v1.0.0", "assets": [
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
}
// The signature is what Windows checks, but a package offered over an open
// connection is not worth downloading in the first place
[Fact]
public async Task A_package_offered_over_an_open_connection_is_passed_over()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] }
]
""";
Assert.Null(await Read(releases));
}
[Fact]
public async Task A_bundle_wins_over_the_packages_of_single_architectures()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" },
{ "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString());
}
[Fact]
public async Task Out_of_several_packages_the_one_for_this_machine_is_taken()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" },
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] }
]
""";
string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? "https://host/arm64.msix"
: "https://host/x64.msix";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(expected, release.Package.Url.ToString());
}
// Without the architecture in the name there is no telling which package is
// for this machine — unless it is the only one there
[Fact]
public async Task A_package_without_an_architecture_is_taken_only_when_alone()
{
const string alone = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] }
]
""";
const string ambiguous = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" },
{ "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] }
]
""";
Assert.NotNull(await Read(alone));
Assert.Null(await Read(ambiguous));
}
[Fact]
public async Task An_empty_list_of_releases_means_nothing_to_offer()
{
Assert.Null(await Read("[]"));
}
// A server answering with something else is no reason to fail
[Fact]
public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing()
{
Assert.Null(await Read("""{ "message": "Not Found" }"""));
}
[Fact]
public async Task A_refusal_of_the_server_is_raised()
{
var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized);
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
await Assert.ThrowsAsync<HttpRequestException>(
() => feed.GetLatestAsync(TestContext.Current.CancellationToken));
}
private static string WithTag(string tag) => $$"""
[
{ "tag_name": "{{tag}}", "assets": [
{ "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
]
""";
private static UpdateOptions Options(string? token = null) => new()
{
ServiceUri = new Uri("https://git.alrakis.kz/"),
Project = "alrakis/cursor-lang",
AccessToken = token,
};
private static Task<ReleaseInfo?> Read(string json) =>
new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options())
.GetLatestAsync(TestContext.Current.CancellationToken);
}
@@ -0,0 +1,286 @@
using System.Collections.Concurrent;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.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();
Pump.Run(service.Start);
world.LocaleId = Russian;
Pump.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();
Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
world.LocaleId = Russian;
Pump.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();
Pump.Run(service.Start);
world.ForegroundWindow = SecondWindow;
Pump.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();
Pump.Run(service.Start);
for (int i = 0; i < 5; i++)
{
Pump.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();
Pump.Run(service.Start);
world.ForegroundWindow = IntPtr.Zero;
world.LocaleId = Russian;
Pump.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;
Pump.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();
Pump.Run(service.Start);
world.LocaleId = Russian;
Pump.Run(service.Poll);
Pump.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;
Pump.Run(service.Start);
Pump.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));
Pump.Run(service.Start);
world.LocaleId = Russian;
Pump.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));
Pump.Run(service.Start);
Pump.Run(service.Stop);
world.LocaleId = Russian;
Pump.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));
Pump.Run(service.Start);
Pump.Run(service.Dispose);
world.LocaleId = Russian;
Pump.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));
Pump.Run(service.Start);
Pump.Run(service.Stop);
Pump.Run(service.Start);
world.LocaleId = Russian;
Pump.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 = Pump.Run(() =>
new KeyboardLayoutService(new KeyboardLayoutOptions { PollInterval = TimeSpan.FromHours(1) }));
try
{
Pump.Run(service.Start);
Pump.Run(service.Poll);
Assert.InRange(service.Current.LocaleId, 1, 0xFFFF);
Pump.Run(service.Stop);
}
finally
{
Pump.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)
{
var options = new KeyboardLayoutOptions
{
PollInterval = pollInterval ?? TimeSpan.FromHours(1),
};
_service = Pump.Run(() => new KeyboardLayoutService(
options,
() => ForegroundWindow,
() => LocaleId,
() => SwitchRequests++));
_service.LayoutChanged += (_, e) => Changes.Enqueue(e);
return _service;
}
public void Dispose()
{
if (_service is not null)
{
Pump.Run(_service.Dispose);
}
}
}
}
@@ -0,0 +1,116 @@
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.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,149 @@
using System.ComponentModel;
using System.Globalization;
using System.Windows.Data;
using CursorLang.Core.Services;
namespace CursorLang.Core.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,229 @@
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
namespace CursorLang.Core.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.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
using Microsoft.Win32;
namespace CursorLang.Core.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,552 @@
using System.Drawing;
using System.Globalization;
using System.Text.Json;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Tests.Models;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Keeping the settings in a file. Only the settings window writes, and it asks for
/// that with TrackChanges; the agent loads the same file and never saves. 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 = Pump.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 = Pump.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();
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.FontSize = 42;
settings.Theme = AppTheme.Dark;
settings.PlacementMode = PopupPlacementMode.AtCaret;
settings.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33);
settings.UseCapsLockHotkey = true;
service.Save();
});
AppSettings restored = Pump.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.FromArgb(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();
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.Theme = AppTheme.Dark;
settings.BackgroundColor = Color.FromArgb(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");
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
service.TrackChanges();
settings.FontSize = 33;
// Right after the edit there is nothing on disk yet: the write is deferred
Assert.False(File.Exists(path));
Pump.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");
Pump.Run(() =>
{
using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150));
AppSettings settings = service.Load();
service.TrackChanges();
for (int i = 0; i < 10; i++)
{
settings.Opacity = 0.5 + (i * 0.01);
Assert.False(File.Exists(path));
Pump.Pause(TimeSpan.FromMilliseconds(10));
}
Pump.WaitFor(() => File.Exists(path), "the write happened after the pause in edits");
});
}
/// <summary>
/// Asking to track changes before reading the file still tracks them.
/// </summary>
/// <remarks>
/// The settings window asks in exactly that order: its container hands out the
/// service first and the settings only when something needs them. A version of this
/// that quietly did nothing when the file had not been read yet left the window
/// saving nothing at all — neither while it was open nor when it was closed.
/// </remarks>
[Fact]
public void Tracking_asked_for_before_the_file_is_read_still_saves()
{
using var folder = new TempFolder();
string path = folder.File("settings.json");
Pump.Run(() =>
{
using SettingsService service = Create(folder);
// Before Load, the way the settings window does it
service.TrackChanges();
AppSettings settings = service.Load();
settings.FontSize = 29;
Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer");
});
Assert.Contains("\"FontSize\": 29", File.ReadAllText(path), StringComparison.Ordinal);
}
// Two reads would mean two instances, and the window would edit one while the
// service saved the other
[Fact]
public void Reading_twice_hands_out_the_same_settings()
{
using var folder = new TempFolder();
Pump.Run(() =>
{
using SettingsService service = Create(folder);
Assert.Same(service.Load(), service.Load());
});
}
/// <summary>
/// Re-reading pours the file into the instance everything is already bound to.
/// </summary>
/// <remarks>
/// This is the agent's whole side of the connection: the settings window writes and
/// says so, and the agent calls this. Replacing the instance instead of filling it
/// would leave the popup, the hook and the timers bound to the old one.
/// </remarks>
[Fact]
public void Re_reading_lands_in_the_settings_already_in_hand()
{
using var folder = new TempFolder();
string path = folder.File("settings.json");
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
File.WriteAllText(path, """{"FontSize": 31, "BackgroundColor": "#FF102030"}""");
service.Reload();
Assert.Equal(31, settings.FontSize);
Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.BackgroundColor);
});
}
// A file that has gone missing or turned to nonsense leaves the settings alone:
// showing the popup with yesterday's colours beats showing it with none
[Fact]
public void Re_reading_an_unreadable_file_keeps_what_was_already_there()
{
using var folder = new TempFolder();
string path = folder.File("settings.json");
Pump.Run(() =>
{
using SettingsService service = Create(folder);
AppSettings settings = service.Load();
settings.FontSize = 44;
File.WriteAllText(path, "not json at all");
service.Reload();
Assert.Equal(44, settings.FontSize);
});
}
[Fact]
public void Closing_the_service_saves_the_latest_edits()
{
using var folder = new TempFolder();
Pump.Run(() =>
{
SettingsService service = Create(folder);
AppSettings settings = service.Load();
service.TrackChanges();
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");
Pump.Run(() =>
{
SettingsService service = Create(folder);
AppSettings settings = service.Load();
service.TrackChanges();
service.Dispose();
string afterDispose = File.ReadAllText(path);
settings.FontSize = 99;
Pump.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 = Pump.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);
Pump.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 = Pump.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");
Pump.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 = Pump.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 = Pump.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 = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
});
Assert.Equal(Color.FromArgb(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 = Pump.Run(() =>
{
using SettingsService service = Create(folder);
return service.Load();
});
Assert.Equal(Color.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");
Pump.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);
Pump.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();
Pump.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();
Pump.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();
Pump.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()
{
Pump.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,207 @@
using System.Collections.Concurrent;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.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 = Pump.Run(() => new SingleInstanceGate(suffix));
try
{
Assert.True(Pump.Run(gate.TryAcquire));
}
finally
{
Pump.Run(gate.Dispose);
}
}
[Fact]
public void The_second_run_does_not_get_the_place()
{
string suffix = UniqueSuffix();
SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
try
{
Assert.True(Pump.Run(first.TryAcquire));
Assert.False(TryAcquireApart(suffix));
}
finally
{
Pump.Run(first.Dispose);
}
}
[Fact]
public void The_second_run_asks_the_running_one_to_show_its_window()
{
string suffix = UniqueSuffix();
SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
ConcurrentQueue<EventArgs> requests = new();
first.ActivationRequested += (_, e) => requests.Enqueue(e);
try
{
Pump.Run(first.TryAcquire);
Assert.False(TryAcquireApart(suffix));
Pump.WaitFor(() => !requests.IsEmpty, "the running instance got the request to show itself");
}
finally
{
Pump.Run(first.Dispose);
}
}
[Fact]
public void Without_a_second_run_no_request_arrives()
{
string suffix = UniqueSuffix();
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
ConcurrentQueue<EventArgs> requests = new();
gate.ActivationRequested += (_, e) => requests.Enqueue(e);
try
{
Pump.Run(gate.TryAcquire);
Pump.Pause(TimeSpan.FromMilliseconds(80));
Assert.Empty(requests);
}
finally
{
Pump.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 = Pump.Run(() => new SingleInstanceGate(suffix));
Assert.True(Pump.Run(first.TryAcquire));
Pump.Run(first.Dispose);
Assert.True(TryAcquireApart(suffix));
}
[Fact]
public void No_requests_arrive_after_the_exit()
{
string suffix = UniqueSuffix();
SingleInstanceGate first = Pump.Run(() => new SingleInstanceGate(suffix));
ConcurrentQueue<EventArgs> requests = new();
first.ActivationRequested += (_, e) => requests.Enqueue(e);
Pump.Run(first.TryAcquire);
Pump.Run(first.Dispose);
// The place is free, so the new run simply takes it for itself
Assert.True(TryAcquireApart(suffix));
Pump.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
Pump.RunApart(() =>
{
var abandoned = new Mutex(initiallyOwned: false, "CursorLang.SingleInstance" + suffix);
abandoned.WaitOne(TimeSpan.Zero, exitContext: false);
});
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(suffix));
try
{
Assert.True(Pump.Run(gate.TryAcquire));
}
finally
{
Pump.Run(gate.Dispose);
}
}
[Fact]
public void Closing_without_taking_the_place_passes_without_consequence()
{
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(UniqueSuffix()));
Pump.Run(gate.Dispose);
}
// Each half of the application takes a place of its own: one background process
// and one settings window, and neither gets in the other's way
[Theory]
[InlineData(SingleInstanceGate.AgentName)]
[InlineData(SingleInstanceGate.SettingsName)]
public void Each_half_of_the_application_takes_a_place_of_its_own(string name)
{
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(name));
// The place may be held by a running application — then it is simply not taken
Pump.Run(gate.Dispose);
}
[Fact]
public void Closing_twice_passes_without_consequence()
{
SingleInstanceGate gate = Pump.Run(() => new SingleInstanceGate(UniqueSuffix()));
Pump.Run(gate.TryAcquire);
Pump.Run(gate.Dispose);
Pump.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;
Pump.RunApart(() =>
{
var gate = new SingleInstanceGate(suffix);
try
{
acquired = gate.TryAcquire();
}
finally
{
gate.Dispose();
}
});
return acquired;
}
}
@@ -0,0 +1,98 @@
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Telling a launch by Windows apart from a launch by the user: the first one goes
/// to the tray without a window, the second one is what the window is for.
/// </summary>
public sealed class StartupLaunchTests
{
[Fact]
public void A_launch_by_the_user_carries_no_argument()
{
Assert.False(StartupLaunch.HasArgument([]));
Assert.False(StartupLaunch.IsAutomatic([]));
}
[Fact]
public void The_startup_entry_says_so_in_the_command_line()
{
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument]));
Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument]));
}
// The argument does not have to come first: Windows may put its own
// alongside it one day
[Fact]
public void The_argument_is_looked_for_among_the_others()
{
Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument]));
}
[Fact]
public void The_case_of_the_argument_does_not_matter()
{
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()]));
}
[Fact]
public void Anything_else_is_a_launch_by_the_user()
{
Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"]));
}
/// <summary>
/// The command written into the registry carries the argument: that is the whole
/// point of the argument.
/// </summary>
[Fact]
public void The_startup_entry_is_written_with_the_argument()
{
using var agent = new StagedAgentExecutable();
string? command = RegistryStartup.GetCommand();
Assert.NotNull(command);
Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal);
// And the path itself stays quoted: it has spaces in it more often than not
Assert.StartsWith("\"", command, StringComparison.Ordinal);
}
/// <summary>
/// Windows must start the agent, whoever asked for it.
/// </summary>
/// <remarks>
/// The checkbox lives in the settings window, which is a process of its own. Were
/// the entry written from the path of whoever is running, the startup list would
/// hold the settings window — a process that shows a window and exits, instead of
/// the one that is supposed to sit in the tray.
/// </remarks>
[Fact]
public void The_startup_entry_names_the_agent_rather_than_whoever_wrote_it()
{
using var agent = new StagedAgentExecutable();
string? command = RegistryStartup.GetCommand();
Assert.NotNull(command);
Assert.Contains("CursorLang.exe", command, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("CursorLang.Settings.exe", command, StringComparison.OrdinalIgnoreCase);
}
// Without an agent on disk there is nothing to put in the startup list, and
// pointing Windows at a file that is not there would be worse than saying nothing
[Fact]
public void Without_an_agent_on_disk_there_is_no_entry_to_write()
{
string agent = Path.Combine(AppContext.BaseDirectory, "CursorLang.exe");
if (File.Exists(agent))
{
Assert.Skip("The agent is built into the test output folder — nothing to check here");
}
Assert.Null(RegistryStartup.GetCommand());
}
}
@@ -0,0 +1,58 @@
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
using Windows.ApplicationModel;
namespace CursorLang.Core.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;
}
using var agent = new StagedAgentExecutable();
// 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,38 @@
using CursorLang.Core.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Where the app looks for its releases. The values belong to the build, and a
/// wrong one shows only as an update that never arrives.
/// </summary>
public sealed class UpdateOptionsTests
{
[Fact]
public void Out_of_the_box_the_releases_are_looked_for_in_the_repository_of_the_app()
{
var options = new UpdateOptions();
Assert.Equal("git.alrakis.kz", options.ServiceUri.Host);
Assert.Equal("alrakis/cursor-lang", options.Project);
}
[Fact]
public void Another_server_is_taken_as_it_is_given()
{
var options = new UpdateOptions
{
ServiceUri = new Uri("https://git.example.com/"),
Project = "team/app",
};
Assert.Equal("git.example.com", options.ServiceUri.Host);
Assert.Equal("team/app", options.Project);
}
[Fact]
public void The_app_asks_about_releases_no_more_than_once_a_day()
{
Assert.Equal(TimeSpan.FromDays(1), new UpdateOptions().CheckInterval);
}
}
@@ -0,0 +1,156 @@
using System.Net;
using System.Text;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// What the app does with a release once it has found one: whether it is newer
/// at all, and what ends up on disk.
/// </summary>
public sealed class UpdateServiceTests
{
[Theory]
[InlineData("1.0.0.0", "1.0.1.0", true)]
[InlineData("1.0.0.0", "2.0.0.0", true)]
[InlineData("1.0.0.0", "1.0.0.0", false)]
[InlineData("1.0.1.0", "1.0.0.0", false)]
public async Task Only_a_higher_version_counts_as_an_update(string current, string found, bool offered)
{
var feed = new FakeReleaseFeed { Release = Release(found) };
using TempFolder folder = new();
using UpdateService service = Create(feed, folder, current);
ReleaseInfo? update = await service.CheckAsync(TestContext.Current.CancellationToken);
Assert.Equal(offered, update is not null);
}
[Fact]
public async Task An_empty_repository_leaves_the_app_with_nothing()
{
var feed = new FakeReleaseFeed { Release = null };
using TempFolder folder = new();
using UpdateService service = Create(feed, folder);
Assert.Null(await service.CheckAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task The_package_ends_up_on_disk_whole()
{
byte[] content = Encoding.UTF8.GetBytes(new string('p', 300_000));
using TempFolder folder = new();
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
Assert.Equal(content, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken));
}
// The name comes from the version, not from the answer: the app creates a
// file with it, and the answer comes from the other side
[Fact]
public async Task The_name_of_the_file_is_built_by_the_app_itself()
{
using TempFolder folder = new();
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1, 2, 3]));
var release = new ReleaseInfo(
new Version(2, 0, 0, 0),
"v2.0.0",
null,
new ReleaseAsset(@"..\..\evil.msixbundle", new Uri("https://host/a"), 3));
string path = await service.DownloadAsync(release, null, TestContext.Current.CancellationToken);
Assert.Equal("CursorLang-2.0.0.0.msixbundle", Path.GetFileName(path));
Assert.Equal(folder.Path, Path.GetDirectoryName(path));
}
[Fact]
public async Task The_download_reports_how_far_it_has_come()
{
byte[] content = new byte[500_000];
using TempFolder folder = new();
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
var reported = new CollectingProgress();
await service.DownloadAsync(Release("2.0.0.0"), reported, TestContext.Current.CancellationToken);
Assert.NotEmpty(reported.Values);
Assert.All(reported.Values, value => Assert.InRange(value, 0, 1));
Assert.Equal(reported.Values, [.. reported.Values.Order()]);
Assert.Equal(1, reported.Values[^1]);
}
[Fact]
public async Task A_closed_repository_gets_the_token_with_the_download_too()
{
var feed = new FakeReleaseFeed();
using TempFolder folder = new();
using UpdateService service = Create(feed, folder, client: FakeHttpHandler.Bytes([1]));
await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
Assert.Single(feed.Authorized);
}
[Fact]
public async Task A_refusal_of_the_hosting_service_leaves_no_package_behind()
{
using TempFolder folder = new();
using UpdateService service = Create(
new FakeReleaseFeed(), folder, client: FakeHttpHandler.Status(HttpStatusCode.NotFound));
await Assert.ThrowsAsync<HttpRequestException>(
() => service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken));
Assert.Empty(Directory.GetFiles(folder.Path));
}
// A package left from an earlier download takes up room and is of no use
// once it has been installed
[Fact]
public async Task An_older_download_is_cleared_away()
{
using TempFolder folder = new();
string leftover = folder.File("CursorLang-1.5.0.0.msixbundle");
await File.WriteAllTextAsync(leftover, "old", TestContext.Current.CancellationToken);
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1]));
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
Assert.False(File.Exists(leftover));
Assert.True(File.Exists(path));
}
/// <summary>
/// The reports as the download makes them. <c>Progress&lt;T&gt;</c> would
/// hand them over to another thread, and a test has nowhere to wait for that.
/// </summary>
private sealed class CollectingProgress : IProgress<double>
{
internal List<double> Values { get; } = [];
public void Report(double value) => Values.Add(value);
}
private static ReleaseInfo Release(string version) => new(
Version.Parse(version),
$"v{version}",
new Uri("https://host/releases/tag"),
new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0));
private static UpdateService Create(
IReleaseFeed feed,
TempFolder folder,
string current = "1.0.0.0",
FakeHttpHandler? client = null) =>
new(feed,
(client ?? FakeHttpHandler.Bytes([])).CreateClient(),
Version.Parse(current),
folder.Path);
}