added tests to project

This commit is contained in:
2026-08-09 18:31:43 +05:00
parent 6da32504f9
commit d5dc228ebe
39 changed files with 6491 additions and 0 deletions
@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http;
using System.Text;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// The network as the test writes it: the answer is decided here rather than
/// by a repository somewhere.
/// </summary>
internal sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _reply;
internal FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> reply) => _reply = reply;
internal List<HttpRequestMessage> Requests { get; } = [];
internal static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
internal static FakeHttpHandler Status(HttpStatusCode status) =>
new(_ => new HttpResponseMessage(status));
internal static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(content),
});
internal HttpClient CreateClient() => new(this);
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(_reply(request));
}
}
+289
View File
@@ -0,0 +1,289 @@
using System.ComponentModel;
using System.Net.Http;
using CursorLang.Models;
using CursorLang.Services;
using CursorLang.ViewModels;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// Parts every test needs but few tests care about.
/// </summary>
internal static class Fake
{
internal static UpdateViewModel Updates() =>
new(new FakeUpdateService(), new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
}
/// <summary>
/// The keyboard layout, with the test in charge of it.
/// </summary>
internal sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
{
internal int StartCalls { get; private set; }
internal int StopCalls { get; private set; }
internal int SwitchCalls { get; private set; }
internal KeyboardLayout CurrentLayout { get; set; } = KeyboardLayout.FromLocaleId(0x0409);
public KeyboardLayout Current => CurrentLayout;
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
public void Start() => StartCalls++;
public void Stop() => StopCalls++;
public void SwitchToNext() => SwitchCalls++;
internal void RaiseLayoutChanged(KeyboardLayout layout, LayoutChangeReason reason) =>
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(layout, reason));
internal bool HasSubscribers => LayoutChanged is not null;
}
/// <summary>
/// A tooltip that pops up nowhere and merely remembers what it was asked for.
/// </summary>
internal sealed class FakeLayoutPopupService : ILayoutPopupService
{
internal List<KeyboardLayout> Shown { get; } = [];
internal List<KeyboardLayout> ShownUntilHidden { get; } = [];
internal int HideCalls { get; private set; }
public void Show(KeyboardLayout layout) => Shown.Add(layout);
public void ShowUntilHidden(KeyboardLayout layout) => ShownUntilHidden.Add(layout);
public void Hide() => HideCalls++;
}
/// <summary>
/// The Caps Lock interception without intercepting anything: the test supplies the presses.
/// </summary>
internal sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
{
public event EventHandler? Tapped;
public event EventHandler? HoldStarted;
public event EventHandler? HoldEnded;
public bool IsRunning { get; private set; }
internal int StartCalls { get; private set; }
internal int StopCalls { get; private set; }
public void Start()
{
StartCalls++;
IsRunning = true;
}
public void Stop()
{
StopCalls++;
IsRunning = false;
}
internal void RaiseTapped() => Tapped?.Invoke(this, EventArgs.Empty);
internal void RaiseHoldStarted() => HoldStarted?.Invoke(this, EventArgs.Empty);
internal void RaiseHoldEnded() => HoldEnded?.Invoke(this, EventArgs.Empty);
internal bool HasSubscribers => Tapped is not null || HoldStarted is not null || HoldEnded is not null;
}
/// <summary>
/// Startup whose state the test assigns.
/// </summary>
internal sealed class FakeStartupService : IStartupService
{
internal StartupState State { get; set; } = StartupState.Disabled;
internal StartupState? AnswerOnEnable { get; set; }
internal List<bool> Requests { get; } = [];
internal int GetStateCalls { get; private set; }
public Task<StartupState> GetStateAsync()
{
GetStateCalls++;
return Task.FromResult(State);
}
public Task<StartupState> SetEnabledAsync(bool enabled)
{
Requests.Add(enabled);
State = enabled
? AnswerOnEnable ?? StartupState.Enabled
: StartupState.Disabled;
return Task.FromResult(State);
}
}
/// <summary>
/// Releases the test writes itself, with no repository behind them.
/// </summary>
internal sealed class FakeUpdateService : IUpdateService
{
internal ReleaseInfo? Release { get; set; }
internal Exception? Failure { get; set; }
internal TaskCompletionSource? DownloadGate { get; set; }
internal string PackagePath { get; set; } = string.Empty;
internal int CheckCalls { get; private set; }
internal List<string> Installed { get; } = [];
public bool IsSupported { get; set; } = true;
public Version CurrentVersion { get; set; } = new(1, 0, 0, 0);
public Task<ReleaseInfo?> CheckAsync(CancellationToken cancellationToken)
{
CheckCalls++;
return Failure is null
? Task.FromResult(Release)
: Task.FromException<ReleaseInfo?>(Failure);
}
public async Task<string> DownloadAsync(
ReleaseInfo release,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
if (DownloadGate is not null)
{
await DownloadGate.Task.WaitAsync(cancellationToken);
}
if (Failure is not null)
{
throw Failure;
}
progress?.Report(0.5);
return PackagePath;
}
public void Install(string packagePath) => Installed.Add(packagePath);
}
/// <summary>
/// A release list the test fills in, with no repository behind it.
/// </summary>
internal sealed class FakeReleaseFeed : IReleaseFeed
{
internal ReleaseInfo? Release { get; set; }
internal Exception? Failure { get; set; }
internal List<HttpRequestMessage> Authorized { get; } = [];
public Task<ReleaseInfo?> GetLatestAsync(CancellationToken cancellationToken) =>
Failure is null ? Task.FromResult(Release) : Task.FromException<ReleaseInfo?>(Failure);
public void Authorize(HttpRequestMessage request) => Authorized.Add(request);
}
/// <summary>
/// Interface strings without resources: the key comes back as is, tagged with the language.
/// </summary>
internal sealed class FakeLocalizationService : ILocalizationService
{
private string _currentLanguage = "en";
public event PropertyChangedEventHandler? PropertyChanged;
internal List<string> RequestedKeys { get; } = [];
public string this[string key]
{
get
{
RequestedKeys.Add(key);
return $"{_currentLanguage}:{key}";
}
}
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
[
new("en", "English"),
new("ru", "Русский"),
];
public string CurrentLanguage
{
get => _currentLanguage;
set
{
if (string.IsNullOrWhiteSpace(value) || value == _currentLanguage)
{
return;
}
_currentLanguage = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentLanguage)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(System.Windows.Data.Binding.IndexerName));
}
}
}
/// <summary>
/// A theme that paints nothing and only remembers the windows attached to it.
/// </summary>
internal sealed class FakeThemeService : IThemeService
{
public AppTheme CurrentTheme { get; set; } = AppTheme.Light;
internal List<System.Windows.Window> Registered { get; } = [];
public void Register(System.Windows.Window window) => Registered.Add(window);
}
/// <summary>
/// A tooltip window that shows nothing.
/// </summary>
internal sealed class FakeLayoutPopupWindow : ILayoutPopupWindow
{
internal int ShowCalls { get; private set; }
internal int HideCalls { get; private set; }
internal int CloseCalls { get; private set; }
internal List<string> Calls { get; } = [];
public void ShowPopup()
{
ShowCalls++;
Calls.Add("show");
}
public void Hide()
{
HideCalls++;
Calls.Add("hide");
}
public void Close()
{
CloseCalls++;
Calls.Add("close");
}
}
+179
View File
@@ -0,0 +1,179 @@
using System.Windows;
using System.Windows.Threading;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// The user interface thread for the tests.
/// </summary>
/// <remarks>
/// Half of the application — windows, the dispatcher and the timers on it —
/// only works on an STA thread with a message queue, while tests run on a pool
/// thread. The thread is therefore started once for the whole run: a process
/// may hold only one <see cref="Application"/>, and recreating it between tests
/// is not possible.
///
/// The queue on that thread is pumped for real, so timers fire on their own:
/// the test only has to await the consequences through <see cref="WaitFor"/>.
/// </remarks>
internal static class Sta
{
private static readonly Lock Gate = new();
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
internal static Dispatcher Dispatcher
{
get
{
lock (Gate)
{
return field ??= Start();
}
}
}
/// <summary>Runs an action on the interface thread and waits for it to finish.</summary>
internal static void Run(Action action) => Dispatcher.Invoke(action);
/// <summary>The same for an action that returns a result.</summary>
internal static TResult Run<TResult>(Func<TResult> action) => Dispatcher.Invoke(action);
/// <summary>
/// Runs an action on a separate STA thread and waits for it to finish.
/// </summary>
/// <remarks>
/// Needed where the foreignness of the thread is the point: kernel objects
/// such as a mutex let their own owner in again, so a "second instance" of
/// the application on the same thread does not count as second.
/// </remarks>
internal static void RunApart(Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception e)
{
failure = e;
}
})
{
IsBackground = true,
Name = "CursorLang.Tests apart",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure is not null)
{
throw new InvalidOperationException("The action on the separate thread failed", failure);
}
}
/// <summary>
/// Waits until the dispatcher queue drains: calls deferred through
/// <c>BeginInvoke</c> have run by that time.
/// </summary>
internal static void Drain() =>
Dispatcher.Invoke(static () => { }, DispatcherPriority.ApplicationIdle);
/// <summary>
/// Waits for a condition without getting in the way of the timers.
/// </summary>
/// <remarks>
/// Waiting is allowed from any thread, including the interface thread
/// itself: there a plain wait would stop the message queue, and with it
/// everything being waited for. On the interface thread the queue therefore
/// keeps being pumped by a nested loop.
/// </remarks>
internal static void WaitFor(Func<bool> condition, string because, TimeSpan? timeout = null)
{
DateTime deadline = DateTime.UtcNow + (timeout ?? DefaultTimeout);
while (!condition())
{
Assert.True(DateTime.UtcNow < deadline, $"Waited in vain: {because}");
Idle(TimeSpan.FromMilliseconds(5));
}
}
/// <summary>
/// Waits for the given time while still pumping the queue: that is how
/// "nothing happened during this time" is verified.
/// </summary>
internal static void Pause(TimeSpan duration)
{
Idle(duration);
Drain();
}
// A wait during which the dispatcher queue gets its chance to run
private static void Idle(TimeSpan duration)
{
if (Dispatcher.CheckAccess())
{
var frame = new DispatcherFrame();
var timer = new DispatcherTimer(
duration,
DispatcherPriority.Background,
(_, _) => frame.Continue = false,
Dispatcher);
try
{
Dispatcher.PushFrame(frame);
}
finally
{
timer.Stop();
}
return;
}
Thread.Sleep(duration);
}
private static Dispatcher Start()
{
var ready = new TaskCompletionSource<Dispatcher>();
var thread = new Thread(() =>
{
Dispatcher dispatcher = Dispatcher.CurrentDispatcher;
// The application has no reason to shut down after its windows:
// tests open and close them by the dozen
var application = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
// The shared style dictionary is merged by App.xaml, which the tests
// do not have. Without it the settings window still builds, but not
// the way the user will see it
application.Resources.MergedDictionaries.Add(new ResourceDictionary
{
Source = new Uri(
"pack://application:,,,/CursorLang;component/Themes/Controls.xaml",
UriKind.Absolute),
});
ready.SetResult(dispatcher);
Dispatcher.Run();
})
{
IsBackground = true,
Name = "CursorLang.Tests UI",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return ready.Task.GetAwaiter().GetResult();
}
}
@@ -0,0 +1,39 @@
using System.IO;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// An empty folder for the lifetime of one test. Settings live in a file, and
/// working with that file has to be verified where nothing is worth losing.
/// </summary>
internal sealed class TempFolder : IDisposable
{
internal TempFolder()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"CursorLang.Tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
internal string Path { get; }
internal string File(string name) => System.IO.Path.Combine(Path, name);
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Litter in the temp folder is no reason to fail a test that passed
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.Win32;
namespace CursorLang.Tests.Infrastructure;
/// <summary>
/// A registry key of one test's own. Startup outside a package lives in the
/// registry, and the real startup list of the user is no place to experiment.
/// </summary>
internal sealed class TempRegistryKey : IDisposable
{
private const string Parent = @"Software\CursorLang.Tests";
private readonly string _path;
internal TempRegistryKey()
{
_path = $@"{Parent}\{Guid.NewGuid():N}";
Key = Registry.CurrentUser.CreateSubKey(_path);
}
internal RegistryKey Key { get; }
public void Dispose()
{
Key.Dispose();
try
{
Registry.CurrentUser.DeleteSubKeyTree(_path, throwOnMissingSubKey: false);
}
catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException)
{
// A leftover key is no reason to fail a test that passed
}
}
}