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:
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
<RootNamespace>CursorLang.Tests.Shared</RootNamespace>
|
||||
<AssemblyName>CursorLang.Tests.Shared</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace CursorLang.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// The network as the test writes it: the answer is decided here rather than
|
||||
/// by a repository somewhere.
|
||||
/// </summary>
|
||||
public sealed class FakeHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _reply;
|
||||
|
||||
public FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> reply) => _reply = reply;
|
||||
|
||||
public List<HttpRequestMessage> Requests { get; } = [];
|
||||
|
||||
public static FakeHttpHandler Json(string json) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
|
||||
public static FakeHttpHandler Status(HttpStatusCode status) =>
|
||||
new(_ => new HttpResponseMessage(status));
|
||||
|
||||
public static FakeHttpHandler Bytes(byte[] content) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(content),
|
||||
});
|
||||
|
||||
public HttpClient CreateClient() => new(this);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(_reply(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.ComponentModel;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard layout, with the test in charge of it.
|
||||
/// </summary>
|
||||
public sealed class FakeKeyboardLayoutService : IKeyboardLayoutService
|
||||
{
|
||||
public int StartCalls { get; private set; }
|
||||
|
||||
public int StopCalls { get; private set; }
|
||||
|
||||
public int SwitchCalls { get; private set; }
|
||||
|
||||
public 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++;
|
||||
|
||||
public void RaiseLayoutChanged(KeyboardLayout layout, LayoutChangeReason reason) =>
|
||||
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(layout, reason));
|
||||
|
||||
public bool HasSubscribers => LayoutChanged is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tooltip that pops up nowhere and merely remembers what it was asked for.
|
||||
/// </summary>
|
||||
public sealed class FakeLayoutPopupService : ILayoutPopupService
|
||||
{
|
||||
public List<KeyboardLayout> Shown { get; } = [];
|
||||
|
||||
public List<KeyboardLayout> ShownUntilHidden { get; } = [];
|
||||
|
||||
public 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>
|
||||
public sealed class FakeCapsLockHotkeyService : ICapsLockHotkeyService
|
||||
{
|
||||
public event EventHandler? Tapped;
|
||||
|
||||
public event EventHandler? HoldStarted;
|
||||
|
||||
public event EventHandler? HoldEnded;
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
public int StartCalls { get; private set; }
|
||||
|
||||
public int StopCalls { get; private set; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
StartCalls++;
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
StopCalls++;
|
||||
IsRunning = false;
|
||||
}
|
||||
|
||||
public void RaiseTapped() => Tapped?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public void RaiseHoldStarted() => HoldStarted?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public void RaiseHoldEnded() => HoldEnded?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public bool HasSubscribers => Tapped is not null || HoldStarted is not null || HoldEnded is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup whose state the test assigns.
|
||||
/// </summary>
|
||||
public sealed class FakeStartupService : IStartupService
|
||||
{
|
||||
public StartupState State { get; set; } = StartupState.Disabled;
|
||||
|
||||
public StartupState? AnswerOnEnable { get; set; }
|
||||
|
||||
public List<bool> Requests { get; } = [];
|
||||
|
||||
public 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>
|
||||
public sealed class FakeUpdateService : IUpdateService
|
||||
{
|
||||
public ReleaseInfo? Release { get; set; }
|
||||
|
||||
public Exception? Failure { get; set; }
|
||||
|
||||
public TaskCompletionSource? DownloadGate { get; set; }
|
||||
|
||||
public string PackagePath { get; set; } = string.Empty;
|
||||
|
||||
public int CheckCalls { get; private set; }
|
||||
|
||||
public 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>
|
||||
public sealed class FakeReleaseFeed : IReleaseFeed
|
||||
{
|
||||
public ReleaseInfo? Release { get; set; }
|
||||
|
||||
public Exception? Failure { get; set; }
|
||||
|
||||
public 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>
|
||||
public sealed class FakeLocalizationService : ILocalizationService
|
||||
{
|
||||
private string _currentLanguage = "en";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public 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(LocalizationService.IndexerName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tooltip window that shows nothing.
|
||||
/// </summary>
|
||||
public sealed class FakeLayoutPopupWindow : ILayoutPopupWindow
|
||||
{
|
||||
public int ShowCalls { get; private set; }
|
||||
|
||||
public int HideCalls { get; private set; }
|
||||
|
||||
public int CloseCalls { get; private set; }
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public string? ShownText { get; private set; }
|
||||
|
||||
public void ShowPopup(string shortName)
|
||||
{
|
||||
ShowCalls++;
|
||||
ShownText = shortName;
|
||||
Calls.Add("show");
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
HideCalls++;
|
||||
Calls.Add("hide");
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
CloseCalls++;
|
||||
Calls.Add("close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A thread with a Win32 message loop on it — what the tests have instead of a
|
||||
/// dispatcher.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Core's timers are <c>SetTimer</c> timers, and they only tick where messages are
|
||||
/// pumped. WPF used to provide that thread; Core must not depend on WPF, so the tests
|
||||
/// provide it themselves. The loop is a real one, so timers fire on their own and a
|
||||
/// test only has to await the consequences through <see cref="WaitFor"/>.
|
||||
///
|
||||
/// One thread for the whole run: starting and stopping message loops between tests
|
||||
/// costs more than it proves, and the hook, the timers and the windows under test are
|
||||
/// happy to share.
|
||||
///
|
||||
/// Waiting is allowed from the pump thread itself, and that is the delicate part. A
|
||||
/// plain wait there would stop the queue and with it everything being waited for, so
|
||||
/// on that thread the waiting is done by a nested loop that keeps dispatching.
|
||||
/// </remarks>
|
||||
public static class Pump
|
||||
{
|
||||
private static readonly Lock Gate = new();
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly ConcurrentQueue<Action> Posted = new();
|
||||
|
||||
private static uint _threadId;
|
||||
|
||||
/// <summary>Runs an action on the pump thread and waits for it to finish.</summary>
|
||||
public static void Run(Action action) => Run<object?>(() =>
|
||||
{
|
||||
action();
|
||||
return null;
|
||||
});
|
||||
|
||||
/// <summary>The same for an action that returns a result.</summary>
|
||||
public static TResult Run<TResult>(Func<TResult> action)
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (IsOnPumpThread)
|
||||
{
|
||||
return action();
|
||||
}
|
||||
|
||||
var done = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Posted.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
done.SetResult(action());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
done.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
Wake();
|
||||
|
||||
return done.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands an action to the pump without waiting for it. What the agent gives the
|
||||
/// keyboard hook, so that a test sees the same deferral the application does.
|
||||
/// </summary>
|
||||
public static void Post(Action action)
|
||||
{
|
||||
EnsureStarted();
|
||||
Posted.Enqueue(action);
|
||||
Wake();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public 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 everything posted to the pump has run. Work handed over with
|
||||
/// <see cref="Post"/> has happened by the time this returns.
|
||||
/// </summary>
|
||||
public static void Drain()
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (IsOnPumpThread)
|
||||
{
|
||||
DispatchPending();
|
||||
return;
|
||||
}
|
||||
|
||||
Run(static () => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a condition, letting the pump get on with its work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A timeout is reported as an exception rather than as a failed assertion so that
|
||||
/// this assembly needs no test framework. To the test the difference is the wording
|
||||
/// of the failure, and <paramref name="because"/> carries that either way.
|
||||
/// </remarks>
|
||||
/// <exception cref="TimeoutException">The condition never came true.</exception>
|
||||
public static void WaitFor(Func<bool> condition, string because, TimeSpan? timeout = null)
|
||||
{
|
||||
TimeSpan limit = timeout ?? DefaultTimeout;
|
||||
DateTime deadline = DateTime.UtcNow + limit;
|
||||
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException($"Waited {limit.TotalMilliseconds:N0} ms in vain: {because}");
|
||||
}
|
||||
|
||||
Idle(TimeSpan.FromMilliseconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the given time while the pump keeps running: that is how
|
||||
/// "nothing happened during this time" is verified.
|
||||
/// </summary>
|
||||
public static void Pause(TimeSpan duration)
|
||||
{
|
||||
Idle(duration);
|
||||
Drain();
|
||||
}
|
||||
|
||||
private static bool IsOnPumpThread => _threadId != 0 && GetCurrentThreadId() == _threadId;
|
||||
|
||||
// A wait during which the queue still gets its chance to run. On any thread but the
|
||||
// pump's that is a plain sleep — the pump is elsewhere and busy on its own
|
||||
private static void Idle(TimeSpan duration)
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
if (!IsOnPumpThread)
|
||||
{
|
||||
Thread.Sleep(duration);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime deadline = DateTime.UtcNow + duration;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (!DispatchPending())
|
||||
{
|
||||
// Nothing waiting: give the timers a moment to post something rather
|
||||
// than spinning through the deadline at full speed
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
DispatchPending();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs everything already in the queue — messages and posted work alike — and
|
||||
/// says whether there was anything. Only ever called on the pump thread.
|
||||
/// </summary>
|
||||
private static bool DispatchPending()
|
||||
{
|
||||
var any = false;
|
||||
|
||||
while (Posted.TryDequeue(out Action? action))
|
||||
{
|
||||
action();
|
||||
any = true;
|
||||
}
|
||||
|
||||
while (PeekMessage(out Message message, IntPtr.Zero, 0, 0, PM_REMOVE))
|
||||
{
|
||||
any = true;
|
||||
|
||||
if (message.hwnd == IntPtr.Zero && message.message == WakeMessage)
|
||||
{
|
||||
while (Posted.TryDequeue(out Action? posted))
|
||||
{
|
||||
posted();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
TranslateMessage(ref message);
|
||||
DispatchMessage(ref message);
|
||||
}
|
||||
|
||||
return any;
|
||||
}
|
||||
|
||||
private static void EnsureStarted()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_threadId != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ready = new TaskCompletionSource<uint>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
// The queue has to exist before anyone is told the thread is up:
|
||||
// PostThreadMessage to a thread without one is silently dropped
|
||||
PeekMessage(out Message _, IntPtr.Zero, WM_USER, WM_USER, PM_NOREMOVE);
|
||||
ready.SetResult(GetCurrentThreadId());
|
||||
|
||||
Loop();
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "CursorLang.Tests pump",
|
||||
};
|
||||
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
|
||||
_threadId = ready.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Loop()
|
||||
{
|
||||
while (GetMessage(out Message message, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
if (message.hwnd == IntPtr.Zero && message.message == WakeMessage)
|
||||
{
|
||||
while (Posted.TryDequeue(out Action? action))
|
||||
{
|
||||
action();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
TranslateMessage(ref message);
|
||||
DispatchMessage(ref message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Wake() => PostThreadMessage(_threadId, WakeMessage, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
/// <summary>WM_APP and up belong to the application, and here that is the tests.</summary>
|
||||
private const uint WakeMessage = 0x8000 + 200;
|
||||
|
||||
private const uint WM_USER = 0x0400;
|
||||
private const uint PM_REMOVE = 0x0001;
|
||||
private const uint PM_NOREMOVE = 0x0000;
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetMessageW")]
|
||||
private static extern int GetMessage(out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PeekMessageW")]
|
||||
private static extern bool PeekMessage(
|
||||
out Message lpMsg, IntPtr hWnd, uint filterMin, uint filterMax, uint removeMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool TranslateMessage(ref Message lpMsg);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DispatchMessageW")]
|
||||
private static extern IntPtr DispatchMessage(ref Message lpMsg);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostThreadMessageW")]
|
||||
private static extern bool PostThreadMessage(uint threadId, uint message, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Message
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int x;
|
||||
public int y;
|
||||
public uint lPrivate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace CursorLang.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Puts an agent executable next to the test assembly for the duration of a check.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Startup registration names the agent by path, and the path is worked out from the
|
||||
/// folder the assemblies were loaded from — which for a test run is the test output
|
||||
/// folder, where no agent lives. The file created here is empty and never started; all
|
||||
/// that is asked of it is to exist, because that is the question the resolution asks.
|
||||
/// </remarks>
|
||||
public sealed class StagedAgentExecutable : IDisposable
|
||||
{
|
||||
private readonly string? _created;
|
||||
|
||||
public StagedAgentExecutable()
|
||||
{
|
||||
Path = System.IO.Path.Combine(AppContext.BaseDirectory, "CursorLang.exe");
|
||||
|
||||
if (File.Exists(Path))
|
||||
{
|
||||
// Somebody built the agent into this folder; leave their file alone
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllBytes(Path, []);
|
||||
_created = Path;
|
||||
}
|
||||
|
||||
/// <summary>Where the agent is pretending to be.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_created is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(_created);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Left behind in the build output; harmless, and the next run reuses it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
namespace CursorLang.Tests.Shared;
|
||||
|
||||
/// <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>
|
||||
public sealed class TempFolder : IDisposable
|
||||
{
|
||||
public TempFolder()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"CursorLang.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public 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.Shared;
|
||||
|
||||
/// <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>
|
||||
public sealed class TempRegistryKey : IDisposable
|
||||
{
|
||||
private const string Parent = @"Software\CursorLang.Tests";
|
||||
|
||||
private readonly string _path;
|
||||
|
||||
public TempRegistryKey()
|
||||
{
|
||||
_path = $@"{Parent}\{Guid.NewGuid():N}";
|
||||
Key = Registry.CurrentUser.CreateSubKey(_path);
|
||||
}
|
||||
|
||||
public RegistryKey Key { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Key.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(_path, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
// A leftover key is no reason to fail a test that passed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
Reference in New Issue
Block a user