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,122 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Settings.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CursorLang.Settings.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What the container of the settings process is made of. The tests cannot build the
|
||||
/// application whole — it would raise a window and take the place of the single
|
||||
/// instance — but checking that everything needed is declared and resolvable works
|
||||
/// without that.
|
||||
/// </summary>
|
||||
public sealed class AppTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(SettingsService))]
|
||||
[InlineData(typeof(ThemeService))]
|
||||
[InlineData(typeof(IThemeService))]
|
||||
[InlineData(typeof(MainWindowPlacement))]
|
||||
[InlineData(typeof(ILocalizationService))]
|
||||
[InlineData(typeof(IStartupService))]
|
||||
[InlineData(typeof(IUpdateService))]
|
||||
[InlineData(typeof(UpdateOptions))]
|
||||
[InlineData(typeof(SettingsViewModel))]
|
||||
[InlineData(typeof(UpdateViewModel))]
|
||||
[InlineData(typeof(MainWindow))]
|
||||
[InlineData(typeof(AppSettings))]
|
||||
public void Everything_the_window_needs_is_declared_in_the_container(Type service)
|
||||
{
|
||||
Assert.Contains(Describe(), descriptor => descriptor.ServiceType == service);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The background half is not in here, and must not be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hook, the popup and the layout polling belong to the agent process now. A
|
||||
/// registration of any of them here would mean two applications watching the
|
||||
/// keyboard at once — and the second of them holding WPF while it did so.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("KeyboardLayoutService")]
|
||||
[InlineData("LayoutPopupService")]
|
||||
[InlineData("CapsLockHotkeyService")]
|
||||
[InlineData("LayoutNotificationCoordinator")]
|
||||
[InlineData("CapsLockSwitchCoordinator")]
|
||||
[InlineData("TrayIcon")]
|
||||
public void The_background_half_is_not_in_the_settings_container(string name)
|
||||
{
|
||||
Assert.DoesNotContain(Describe(), descriptor => descriptor.ServiceType.Name.Contains(name));
|
||||
}
|
||||
|
||||
// The settings and the theme have to be shared by the whole window: a second copy
|
||||
// of them would mean lost edits or half the controls in the wrong colours
|
||||
[Fact]
|
||||
public void Everything_in_the_container_is_declared_as_a_single_copy()
|
||||
{
|
||||
Assert.All(Describe(), descriptor =>
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_service_is_declared_once()
|
||||
{
|
||||
var types = Describe().Select(descriptor => descriptor.ServiceType).ToList();
|
||||
|
||||
Assert.Equal(types.Count, types.Distinct().Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dependencies of every service have to be resolvable. The check runs
|
||||
/// while the container is built and creates no services itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_service_dependencies_come_together_with_nothing_missing()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
App.ConfigureServices(services);
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateOnBuild = true,
|
||||
ValidateScopes = true,
|
||||
});
|
||||
|
||||
// Ensure the provider was built successfully — this serves as an assertion
|
||||
// so the test framework recognizes this as a meaningful test.
|
||||
Assert.NotNull(provider);
|
||||
|
||||
provider.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_settings_come_from_the_settings_service()
|
||||
{
|
||||
ServiceDescriptor settings = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(AppSettings));
|
||||
|
||||
// The settings are not created anew but read from disk by the service
|
||||
Assert.NotNull(settings.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_theme_and_its_interface_are_one_service()
|
||||
{
|
||||
ServiceDescriptor theme = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(IThemeService));
|
||||
|
||||
Assert.NotNull(theme.ImplementationFactory);
|
||||
}
|
||||
|
||||
private static ServiceCollection Describe()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
App.ConfigureServices(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>CursorLang.Settings.Tests</RootNamespace>
|
||||
<AssemblyName>CursorLang.Settings.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj" />
|
||||
<ProjectReference Include="..\CursorLang.Tests.Shared\CursorLang.Tests.Shared.csproj" />
|
||||
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The markup is embedded as plain text so that the check for missing resource
|
||||
keys reads what the window really says, without walking the folder tree -->
|
||||
<PropertyGroup>
|
||||
<MainWindowMarkup>$(MSBuildThisFileDirectory)..\CursorLang.Settings\Views\MainWindow.xaml</MainWindowMarkup>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EmbedMainWindowMarkup" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<MainWindowMarkupCopy>$(IntermediateOutputPath)MainWindow.xaml.txt</MainWindowMarkupCopy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Copy SourceFiles="$(MainWindowMarkup)"
|
||||
DestinationFiles="$(MainWindowMarkupCopy)"
|
||||
SkipUnchangedFiles="true" />
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(MainWindowMarkupCopy)" LogicalName="MainWindow.xaml" />
|
||||
<FileWrites Include="$(MainWindowMarkupCopy)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Settings.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>
|
||||
/// 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);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace CursorLang.Settings.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.Settings;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,159 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Settings.Interop;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that need a real foreground window with an input field: the caret
|
||||
/// and the layout switch live exactly there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Windows does not always allow a window to come forward — when the screen is
|
||||
/// locked, say, or when the run happens in a session without a desktop. In
|
||||
/// those cases the check reports itself as skipped rather than failed: there
|
||||
/// would be nothing to verify.
|
||||
/// </remarks>
|
||||
public sealed class ForegroundWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_caret_in_an_input_field_is_found()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var input = new InputWindow();
|
||||
input.RequireForeground();
|
||||
|
||||
PopupWindowNative.Rect? caret = CaretNative.TryGetCaretRect();
|
||||
|
||||
if (caret is null)
|
||||
{
|
||||
Assert.Skip("The input field did not report the caret position");
|
||||
}
|
||||
|
||||
// The caret has to sit inside the input window and to have a height
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
|
||||
|
||||
Assert.True(caret.Value.Bottom > caret.Value.Top);
|
||||
Assert.InRange(caret.Value.Left, bounds.Left, bounds.Right);
|
||||
Assert.InRange(caret.Value.Top, bounds.Top, bounds.Bottom);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_at_the_caret_lands_next_to_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var input = new InputWindow();
|
||||
input.RequireForeground();
|
||||
|
||||
PopupWindowNative.Rect? caret = CaretNative.TryGetCaretRect();
|
||||
if (caret is null)
|
||||
{
|
||||
Assert.Skip("The input field did not report the caret position");
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect field =
|
||||
WindowPlacementNative.TryGetBounds(input.Handle)!.Value;
|
||||
|
||||
Assert.True(CaretNative.IsInside(caret.Value, field),
|
||||
"the caret reported by the input field is outside that field");
|
||||
});
|
||||
}
|
||||
|
||||
// The request to switch the layout goes to the window holding the input
|
||||
// focus, so it only concerns the test window itself
|
||||
[Fact]
|
||||
public void The_request_to_change_the_layout_reaches_its_own_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var input = new InputWindow();
|
||||
input.RequireForeground();
|
||||
|
||||
int before = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
|
||||
KeyboardLayoutNative.RequestNextLayout();
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(150));
|
||||
|
||||
int after = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
|
||||
Assert.InRange(after, 1, 0xFFFF);
|
||||
|
||||
if (after == before)
|
||||
{
|
||||
// The system may hold a single layout — there is nothing to switch to
|
||||
return;
|
||||
}
|
||||
|
||||
// Bring the layout back around the circle to where it was
|
||||
for (int i = 0; i < 8 && KeyboardLayoutNative.GetActiveLocaleId() != before; i++)
|
||||
{
|
||||
KeyboardLayoutNative.RequestNextLayout();
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(150));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A window with an input field brought to the foreground.</summary>
|
||||
private sealed class InputWindow : IDisposable
|
||||
{
|
||||
private readonly Window _window;
|
||||
|
||||
internal InputWindow()
|
||||
{
|
||||
var box = new TextBox { Text = "check", FontSize = 20 };
|
||||
|
||||
_window = new Window
|
||||
{
|
||||
Width = 400,
|
||||
Height = 200,
|
||||
ShowInTaskbar = false,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
Left = 100,
|
||||
Top = 100,
|
||||
Topmost = true,
|
||||
Content = box,
|
||||
};
|
||||
|
||||
_window.Show();
|
||||
Handle = new WindowInteropHelper(_window).Handle;
|
||||
|
||||
// Windows grants the right to bring a window forward neither to
|
||||
// everyone nor at once, so it takes a few attempts
|
||||
for (int attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
_window.Activate();
|
||||
box.Focus();
|
||||
box.CaretIndex = box.Text.Length;
|
||||
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
if (KeyboardLayoutNative.GetForegroundWindow() == Handle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The caret does not appear at the same instant as the focus
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
/// <summary>Skips the check if the window never became the foreground one.</summary>
|
||||
internal void RequireForeground()
|
||||
{
|
||||
if (KeyboardLayoutNative.GetForegroundWindow() != Handle)
|
||||
{
|
||||
Assert.Skip("The window could not be brought to the foreground");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Settings.Interop;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// The Win32 wrappers: what is checked is that the calls are put together
|
||||
/// right — structures of the expected size, flags in place, and the answers
|
||||
/// of the system read correctly.
|
||||
/// </summary>
|
||||
public sealed class NativeWrappersTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_cursor_position_is_read()
|
||||
{
|
||||
PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
|
||||
|
||||
// The virtual screen may run into negative coordinates, but not beyond
|
||||
// reason: a misread structure would give garbage
|
||||
Assert.InRange(cursor.X, -32_000, 32_000);
|
||||
Assert.InRange(cursor.Y, -32_000, 32_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scale_of_the_monitor_under_the_cursor_is_positive()
|
||||
{
|
||||
double scale = PopupWindowNative.GetScaleAt(PopupWindowNative.GetCursorPosition());
|
||||
|
||||
Assert.InRange(scale, 0.5, 8.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_work_area_of_the_active_monitor_is_not_empty()
|
||||
{
|
||||
(PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
|
||||
Assert.True(work.Right > work.Left);
|
||||
Assert.True(work.Bottom > work.Top);
|
||||
Assert.InRange(scale, 0.5, 8.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_bounds_are_read_from_the_system()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.Rect? bounds = WindowPlacementNative.TryGetBounds(window.Handle);
|
||||
|
||||
Assert.NotNull(bounds);
|
||||
Assert.True(bounds.Value.Right > bounds.Value.Left);
|
||||
Assert.True(bounds.Value.Bottom > bounds.Value.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_that_does_not_exist_has_no_bounds()
|
||||
{
|
||||
Assert.Null(WindowPlacementNative.TryGetBounds(IntPtr.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_is_moved_to_the_given_point()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, 120, 90);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(120, bounds.Left);
|
||||
Assert.Equal(90, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Moving_does_not_change_the_window_size()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
PopupWindowNative.Rect before = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
PopupWindowNative.MoveTo(window.Handle, 200, 150);
|
||||
PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
Assert.Equal(before.Right - before.Left, after.Right - after.Left);
|
||||
Assert.Equal(before.Bottom - before.Top, after.Bottom - after.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_work_area_is_found_by_the_rectangle_of_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
PopupWindowNative.Rect? work = WindowPlacementNative.TryGetWorkAreaNear(bounds);
|
||||
|
||||
Assert.NotNull(work);
|
||||
Assert.True(work.Value.Right > work.Value.Left);
|
||||
Assert.True(work.Value.Bottom > work.Value.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// The nearest monitor is picked, so an area is found even for a point far off screen
|
||||
[Fact]
|
||||
public void For_a_rectangle_off_every_screen_the_nearest_monitor_is_taken()
|
||||
{
|
||||
var far = new PopupWindowNative.Rect { Left = 30_000, Top = 30_000, Right = 30_100, Bottom = 30_100 };
|
||||
|
||||
PopupWindowNative.Rect? work = WindowPlacementNative.TryGetWorkAreaNear(far);
|
||||
|
||||
Assert.NotNull(work);
|
||||
Assert.True(work.Value.Right > work.Value.Left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_layout_of_the_foreground_window_is_read()
|
||||
{
|
||||
int localeId = KeyboardLayoutNative.GetActiveLocaleId();
|
||||
|
||||
// The low word of the HKL is the locale identifier, and it is never zero
|
||||
Assert.NotEqual(0, localeId);
|
||||
Assert.InRange(localeId, 1, 0xFFFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_layout_is_read_for_any_window()
|
||||
{
|
||||
int localeId = KeyboardLayoutNative.GetLocaleIdOf(KeyboardLayoutNative.GetForegroundWindow());
|
||||
|
||||
Assert.InRange(localeId, 0, 0xFFFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_input_state_of_the_foreground_is_read()
|
||||
{
|
||||
bool received = ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info);
|
||||
|
||||
if (received)
|
||||
{
|
||||
// The structure size is filled in by the wrapper itself, and it has
|
||||
// to match what Windows expects
|
||||
Assert.Equal(System.Runtime.InteropServices.Marshal.SizeOf<ForegroundInputNative.GuiThreadInfo>(),
|
||||
info.cbSize);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_right_to_show_a_window_is_given_away_without_errors()
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_title_bar_is_repainted()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new HandleWindow();
|
||||
|
||||
WindowThemeNative.SetDarkTitleBar(window.Handle, isDark: true);
|
||||
WindowThemeNative.SetDarkTitleBar(window.Handle, isDark: false);
|
||||
});
|
||||
}
|
||||
|
||||
// The window may be gone by the time of the repaint
|
||||
[Fact]
|
||||
public void Repainting_a_window_that_does_not_exist_passes_silently()
|
||||
{
|
||||
WindowThemeNative.SetDarkTitleBar(IntPtr.Zero, isDark: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_package_flag_is_computed_once_and_does_not_change()
|
||||
{
|
||||
bool first = PackageIdentityNative.IsPackaged;
|
||||
|
||||
Assert.Equal(first, PackageIdentityNative.IsPackaged);
|
||||
}
|
||||
|
||||
/// <summary>A window with a created handle that never appears on screen.</summary>
|
||||
private sealed class HandleWindow : IDisposable
|
||||
{
|
||||
private readonly Window _window;
|
||||
|
||||
internal HandleWindow()
|
||||
{
|
||||
_window = new Window
|
||||
{
|
||||
Width = 300,
|
||||
Height = 200,
|
||||
ShowInTaskbar = false,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
};
|
||||
|
||||
Handle = new WindowInteropHelper(_window).EnsureHandle();
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
public void Dispose() => _window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Settings.Interop;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Placing the settings window: the centre maths and bringing the window back
|
||||
/// into the work area.
|
||||
/// </summary>
|
||||
public sealed class MainWindowPlacementTests
|
||||
{
|
||||
private static readonly PopupWindowNative.Rect Work = new()
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Right = 1000,
|
||||
Bottom = 800,
|
||||
};
|
||||
|
||||
private static readonly PopupWindowNative.Rect Bounds = new()
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Right = 400,
|
||||
Bottom = 300,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void The_centre_follows_the_window_size_and_the_work_area()
|
||||
{
|
||||
PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, Work);
|
||||
|
||||
Assert.Equal((1000 - 400) / 2, point.X);
|
||||
Assert.Equal((800 - 300) / 2, point.Y);
|
||||
}
|
||||
|
||||
// The work area of a second monitor does not start at zero
|
||||
[Fact]
|
||||
public void The_centre_of_a_neighbouring_monitor_is_measured_from_its_left_edge()
|
||||
{
|
||||
var work = new PopupWindowNative.Rect { Left = 1920, Top = 100, Right = 3520, Bottom = 1000 };
|
||||
|
||||
PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, work);
|
||||
|
||||
Assert.Equal(1920 + ((1600 - 400) / 2), point.X);
|
||||
Assert.Equal(100 + ((900 - 300) / 2), point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_inside_the_work_area_stays_where_it_is()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = 120, Y = 90 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(120, clamped.X);
|
||||
Assert.Equal(90, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_past_the_right_edge_is_pulled_back_in()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = 900, Y = 700 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(1000 - 400, clamped.X);
|
||||
Assert.Equal(800 - 300, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_past_the_left_and_top_edges_is_pulled_back_in()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = -500, Y = -400 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(Work.Left, clamped.X);
|
||||
Assert.Equal(Work.Top, clamped.Y);
|
||||
}
|
||||
|
||||
// The window height matches its content and on a short monitor exceeds the
|
||||
// work area. The title bar matters more than the bottom of the window
|
||||
[Fact]
|
||||
public void A_window_taller_than_the_work_area_is_pinned_to_its_top()
|
||||
{
|
||||
var tall = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 400, Bottom = 900 };
|
||||
var position = new PopupWindowNative.Point { X = 0, Y = 300 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, tall, Work);
|
||||
|
||||
Assert.Equal(Work.Top, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_wider_than_the_work_area_is_pinned_to_its_left_edge()
|
||||
{
|
||||
var wide = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1200, Bottom = 300 };
|
||||
var position = new PopupWindowNative.Point { X = 400, Y = 0 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, wide, Work);
|
||||
|
||||
Assert.Equal(Work.Left, clamped.X);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, 0, true)]
|
||||
[InlineData(0, 0, 100, 0, true)]
|
||||
[InlineData(0, 0, 0, 100, true)]
|
||||
[InlineData(100, 100, 100, 200, true)]
|
||||
[InlineData(0, 0, 1, 1, false)]
|
||||
[InlineData(-100, -100, 100, 100, false)]
|
||||
public void An_area_without_width_or_height_counts_as_empty(
|
||||
int left, int top, int right, int bottom, bool expected)
|
||||
{
|
||||
var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
|
||||
|
||||
Assert.Equal(expected, MainWindowPlacement.IsEmpty(rect));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_time_in_a_session_the_window_lands_centred_on_the_active_monitor()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
(PopupWindowNative.Rect before, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
placement.Apply(window);
|
||||
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
|
||||
if (!before.Equals(work))
|
||||
{
|
||||
// The user moved to another monitor right during the check
|
||||
Assert.Skip("The active monitor changed while the check was running");
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
// Pixel precision: the window goes exactly where it was computed to go
|
||||
PopupWindowNative.Point expected = MainWindowPlacement.Clamp(
|
||||
MainWindowPlacement.Center(bounds, work), bounds, work);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_returns_where_the_user_moved_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
placement.Attach(window);
|
||||
placement.Apply(window);
|
||||
|
||||
// Move the window the way the user does it with the mouse
|
||||
PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
PopupWindowNative.MoveTo(window.Handle, centered.Left + 40, centered.Top + 30);
|
||||
window.RaiseLocationChanged();
|
||||
|
||||
// Showing the window again — it has to stay where it was left
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(centered.Left + 40, after.Left);
|
||||
Assert.Equal(centered.Top + 30, after.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// While we move the window ourselves its position must not drift from repeats
|
||||
[Fact]
|
||||
public void Placing_again_does_not_move_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
placement.Attach(window);
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect first = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
placement.Apply(window);
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect third = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(first.Left, third.Left);
|
||||
Assert.Equal(first.Top, third.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_minimised_window_is_not_placed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, 7, 9);
|
||||
window.WindowState = WindowState.Minimized;
|
||||
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(7, bounds.Left);
|
||||
Assert.Equal(9, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_a_handle_is_not_placed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
var window = new Window { Width = 200, Height = 150 };
|
||||
|
||||
// There must be no exception: the window does not exist yet,
|
||||
// so there is nothing to place
|
||||
placement.Apply(window);
|
||||
|
||||
Assert.Equal(IntPtr.Zero, new WindowInteropHelper(window).Handle);
|
||||
});
|
||||
}
|
||||
|
||||
// The place of the window lives in memory only: the set of monitors may be
|
||||
// different by the next run
|
||||
[Fact]
|
||||
public void Every_placement_starts_its_session_afresh()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new TestWindow();
|
||||
|
||||
var first = new MainWindowPlacement();
|
||||
first.Attach(window);
|
||||
first.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, centered.Left + 60, centered.Top + 60);
|
||||
window.RaiseLocationChanged();
|
||||
|
||||
// A new placement knows nothing of the earlier move and centres the window again
|
||||
var second = new MainWindowPlacement();
|
||||
second.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(centered.Left, bounds.Left);
|
||||
Assert.Equal(centered.Top, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A window with a ready handle that never appears on screen: placement
|
||||
/// works with the system bounds, and a created window is enough for those.
|
||||
/// </summary>
|
||||
private sealed class TestWindow : Window, IDisposable
|
||||
{
|
||||
internal TestWindow()
|
||||
{
|
||||
Width = 400;
|
||||
Height = 300;
|
||||
ShowInTaskbar = false;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
Handle = new WindowInteropHelper(this).EnsureHandle();
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
/// <summary>Reports a move the way WPF does after the user acts.</summary>
|
||||
internal void RaiseLocationChanged() => OnLocationChanged(EventArgs.Empty);
|
||||
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The look of the windows. The palette lives in the application resources,
|
||||
/// so everything happens on the interface thread.
|
||||
/// </summary>
|
||||
public sealed class ThemeServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void A_chosen_theme_is_applied_as_is(AppTheme theme)
|
||||
{
|
||||
var settings = new AppSettings { Theme = theme };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
Assert.Equal(theme, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void The_system_theme_is_taken_from_Windows(AppTheme system)
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, () => system);
|
||||
|
||||
Assert.Equal(system, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changing_the_theme_in_the_settings_repaints_the_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
Color light = WindowBackground();
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Dark, service.CurrentTheme);
|
||||
Assert.NotEqual(light, WindowBackground());
|
||||
});
|
||||
}
|
||||
|
||||
// The palette is replaced rather than piled up: otherwise the light one
|
||||
// would still sit under the dark one
|
||||
[Fact]
|
||||
public void The_palette_does_not_pile_up_in_the_resources()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
int before = Application.Current.Resources.MergedDictionaries.Count;
|
||||
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.Theme = AppTheme.Light;
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(before + 1, Application.Current.Resources.MergedDictionaries.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_the_same_theme_again_leaves_the_resources_alone()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
int count = Application.Current.Resources.MergedDictionaries.Count;
|
||||
ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
|
||||
Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
|
||||
});
|
||||
}
|
||||
|
||||
// The other settings have nothing to do with the look
|
||||
[Fact]
|
||||
public void Other_settings_do_not_change_the_theme()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
settings.FontSize = 40;
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_that_already_exists_is_attached_at_once()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window();
|
||||
|
||||
try
|
||||
{
|
||||
_ = new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
// The title bar is painted by Windows, and the only way to check
|
||||
// this is that the call goes through without an error
|
||||
service.Register(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_a_handle_is_attached_once_it_appears()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
|
||||
|
||||
try
|
||||
{
|
||||
service.Register(window);
|
||||
|
||||
// The window is created on show — and the look comes with it
|
||||
window.Show();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The ordinary service takes the theme from the Windows settings
|
||||
[Fact]
|
||||
public void The_service_can_work_with_the_real_Windows_theme()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(new AppSettings { Theme = AppTheme.System });
|
||||
|
||||
Assert.True(service.CurrentTheme is AppTheme.Light or AppTheme.Dark);
|
||||
Assert.Equal(ThemeService.DetectSystemTheme(), service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change from a thread other than the interface one
|
||||
[Fact]
|
||||
public void A_look_change_in_Windows_repaints_the_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
AppTheme system = AppTheme.Light;
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, () => system);
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
|
||||
system = AppTheme.Dark;
|
||||
RaiseUserPreferenceChanged(service);
|
||||
|
||||
Sta.WaitFor(() => service.CurrentTheme == AppTheme.Dark, "the theme was recomputed at the request of Windows");
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change even when nothing changed for the app:
|
||||
// in that case there is nothing to repaint
|
||||
[Fact]
|
||||
public void The_same_theme_does_not_replace_the_resources()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
|
||||
int count = Application.Current.Resources.MergedDictionaries.Count;
|
||||
|
||||
RaiseUserPreferenceChanged(service);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(30));
|
||||
|
||||
Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
|
||||
Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_closed_window_is_forgotten()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window();
|
||||
_ = new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
service.Register(window);
|
||||
window.Close();
|
||||
|
||||
// A theme change must no longer concern the closed window
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Dark, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_service_lets_go_of_the_attached_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
service.Register(window);
|
||||
|
||||
service.Dispose();
|
||||
|
||||
// The window now closes on its own, with no regard for the theme
|
||||
window.Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_the_setting_no_longer_changes_the_theme()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
service.Dispose();
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_requests_from_Windows_go_unanswered()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
AppTheme system = AppTheme.Light;
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, () => system);
|
||||
service.Dispose();
|
||||
|
||||
system = AppTheme.Dark;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void The_palette_of_each_theme_lives_in_the_application_assembly(AppTheme theme)
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var palette = new ResourceDictionary { Source = ThemeService.PaletteUri(theme) };
|
||||
|
||||
Assert.NotEmpty(palette.Keys);
|
||||
Assert.True(palette.Contains("Theme.WindowBackground"));
|
||||
});
|
||||
}
|
||||
|
||||
// The palette address names the application assembly rather than the one
|
||||
// the process started from
|
||||
[Fact]
|
||||
public void The_palette_address_names_the_application_assembly()
|
||||
{
|
||||
// The palettes live with the settings window, not with the agent next to it
|
||||
Assert.Contains(
|
||||
"CursorLang.Settings;component",
|
||||
ThemeService.PaletteUri(AppTheme.Dark).ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_light_and_dark_palettes_share_one_set_of_keys()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var light = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Light) };
|
||||
var dark = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Dark) };
|
||||
|
||||
Assert.Equal(light.Keys.Cast<object>().OrderBy(key => key.ToString()),
|
||||
dark.Keys.Cast<object>().OrderBy(key => key.ToString()));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Windows_theme_is_read_without_errors()
|
||||
{
|
||||
AppTheme theme = ThemeService.DetectSystemTheme();
|
||||
|
||||
// The "follow the system" setting has to yield something definite
|
||||
Assert.True(theme is AppTheme.Light or AppTheme.Dark);
|
||||
}
|
||||
|
||||
// A control the shared dictionary says nothing about keeps the look Windows
|
||||
// gives it and stays light in the dark theme
|
||||
[Theory]
|
||||
[InlineData(typeof(Button))]
|
||||
[InlineData(typeof(ComboBox))]
|
||||
[InlineData(typeof(CheckBox))]
|
||||
[InlineData(typeof(GroupBox))]
|
||||
[InlineData(typeof(ProgressBar))]
|
||||
[InlineData(typeof(Slider))]
|
||||
public void A_control_of_the_window_is_repainted_together_with_the_theme(Type control)
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
Width = 200,
|
||||
Height = 100,
|
||||
Content = (Control)Activator.CreateInstance(control)!,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
IReadOnlyList<Color> light = PaintOf(window);
|
||||
Assert.NotEmpty(light);
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.NotEqual(light, PaintOf(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change through an event that cannot be synthesised:
|
||||
// the test goes straight to the handler that event arrives at
|
||||
private static void RaiseUserPreferenceChanged(ThemeService service)
|
||||
{
|
||||
System.Reflection.MethodInfo handler = typeof(ThemeService)
|
||||
.GetMethod("OnUserPreferenceChanged", System.Reflection.BindingFlags.Instance
|
||||
| System.Reflection.BindingFlags.NonPublic)!;
|
||||
|
||||
handler.Invoke(service, [null, new UserPreferenceChangedEventArgs(UserPreferenceCategory.General)]);
|
||||
}
|
||||
|
||||
private static Color WindowBackground() =>
|
||||
((SolidColorBrush)Application.Current.Resources["Theme.WindowBackground"]).Color;
|
||||
|
||||
/// <summary>
|
||||
/// Every colour the element tree is painted with. What is compared is the
|
||||
/// whole set: which part of a control the palette reaches is its own business.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<Color> PaintOf(DependencyObject root)
|
||||
{
|
||||
var colours = new List<Color>();
|
||||
Collect(root, colours);
|
||||
|
||||
return colours;
|
||||
}
|
||||
|
||||
private static void Collect(DependencyObject node, List<Color> colours)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case Control control:
|
||||
Add(colours, control.Background, control.BorderBrush, control.Foreground);
|
||||
break;
|
||||
case Border border:
|
||||
Add(colours, border.Background, border.BorderBrush);
|
||||
break;
|
||||
case Shape shape:
|
||||
Add(colours, shape.Fill, shape.Stroke);
|
||||
break;
|
||||
case TextBlock text:
|
||||
Add(colours, text.Background, text.Foreground);
|
||||
break;
|
||||
}
|
||||
|
||||
int count = VisualTreeHelper.GetChildrenCount(node);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Collect(VisualTreeHelper.GetChild(node, i), colours);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(List<Color> colours, params Brush?[] brushes) =>
|
||||
colours.AddRange(brushes.OfType<SolidColorBrush>().Select(brush => brush.Color));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
|
||||
namespace CursorLang.Settings.Tests.ViewModels;
|
||||
|
||||
public sealed class EnumOptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void An_option_remembers_its_value_and_caption()
|
||||
{
|
||||
var option = new EnumOption<AppTheme>(AppTheme.Dark, "Dark theme");
|
||||
|
||||
Assert.Equal(AppTheme.Dark, option.Value);
|
||||
Assert.Equal("Dark theme", option.Display);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_changed_caption_is_announced_to_subscribers()
|
||||
{
|
||||
var option = new EnumOption<AppTheme>(AppTheme.Dark, "Dark");
|
||||
List<string?> changed = [];
|
||||
option.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
option.Display = "Dark theme";
|
||||
|
||||
Assert.Equal("Dark theme", option.Display);
|
||||
Assert.Equal([nameof(EnumOption<>.Display)], changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_caption_is_not_announced_again()
|
||||
{
|
||||
var option = new EnumOption<AppTheme>(AppTheme.Dark, "Dark theme");
|
||||
List<string?> changed = [];
|
||||
option.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
option.Display = "Dark theme";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
// Accessibility tools take the name of a list item from here
|
||||
[Fact]
|
||||
public void An_option_presents_itself_by_its_caption()
|
||||
{
|
||||
Assert.Equal("Dark theme", new EnumOption<AppTheme>(AppTheme.Dark, "Dark theme").ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_changed_caption_changes_the_presentation_too()
|
||||
{
|
||||
var option = new EnumOption<ScreenPosition>(ScreenPosition.Center, "Center") { Display = "In the centre" };
|
||||
|
||||
Assert.Equal("In the centre", option.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Settings.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window in terms of what it shows and what it is in charge of.
|
||||
/// </summary>
|
||||
public sealed class SettingsViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_interface_language_comes_from_the_settings()
|
||||
{
|
||||
var settings = new AppSettings { Language = "ru" };
|
||||
var localization = new FakeLocalizationService();
|
||||
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
Assert.Equal("ru", localization.CurrentLanguage);
|
||||
Assert.Same(settings, viewModel.Settings);
|
||||
Assert.Same(localization, viewModel.Localization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changing_the_language_setting_switches_the_interface()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Equal("ru", localization.CurrentLanguage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Other_settings_leave_the_language_alone()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
settings.FontSize = 30;
|
||||
|
||||
Assert.Equal("en", localization.CurrentLanguage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lists_are_built_from_every_value_of_the_enums()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.Equal(Enum.GetValues<AppTheme>(), viewModel.Themes.Select(option => option.Value));
|
||||
Assert.Equal(Enum.GetValues<PopupPlacementMode>(), viewModel.PlacementModes.Select(option => option.Value));
|
||||
Assert.Equal(Enum.GetValues<AnchorSide>(), viewModel.AnchorSides.Select(option => option.Value));
|
||||
Assert.Equal(Enum.GetValues<ScreenPosition>(), viewModel.ScreenPositions.Select(option => option.Value));
|
||||
}
|
||||
|
||||
// A caption key is built from the type name and the value
|
||||
[Fact]
|
||||
public void The_option_captions_come_from_the_resources()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(localization: localization);
|
||||
|
||||
Assert.Equal("en:AppTheme_System", viewModel.Themes[0].Display);
|
||||
Assert.Contains("PopupPlacementMode_AtCursor", localization.RequestedKeys);
|
||||
}
|
||||
|
||||
// If the list items were recreated, the ComboBox would drop the selected value
|
||||
[Fact]
|
||||
public void Changing_the_language_changes_the_captions_not_the_options()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
using var viewModel = Create(settings, localization);
|
||||
|
||||
EnumOption<AppTheme> first = viewModel.Themes[0];
|
||||
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Same(first, viewModel.Themes[0]);
|
||||
Assert.Equal("ru:AppTheme_System", first.Display);
|
||||
Assert.Equal("ru:AnchorSide_TopLeft", viewModel.AnchorSides[0].Display);
|
||||
Assert.Equal("ru:ScreenPosition_TopLeft", viewModel.ScreenPositions[0].Display);
|
||||
Assert.Equal("ru:PopupPlacementMode_AtCursor", viewModel.PlacementModes[0].Display);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_background_and_text_palettes_are_non_empty_and_different()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.NotEmpty(viewModel.BackgroundPalette);
|
||||
Assert.NotEmpty(viewModel.TextPalette);
|
||||
Assert.NotEqual(viewModel.BackgroundPalette, viewModel.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_palettes_hold_no_duplicates()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.Equal(viewModel.BackgroundPalette.Count, viewModel.BackgroundPalette.Distinct().Count());
|
||||
Assert.Equal(viewModel.TextPalette.Count, viewModel.TextPalette.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_default_colours_are_present_in_the_palettes()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
using var viewModel = Create(settings);
|
||||
|
||||
Assert.Contains(settings.BackgroundColor, viewModel.BackgroundPalette);
|
||||
Assert.Contains(settings.ForegroundColor, viewModel.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_palettes_are_the_same_for_every_window()
|
||||
{
|
||||
using SettingsViewModel first = Create();
|
||||
using SettingsViewModel second = Create();
|
||||
|
||||
Assert.Same(first.BackgroundPalette, second.BackgroundPalette);
|
||||
Assert.Same(first.TextPalette, second.TextPalette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_background_palette_consists_of_colours()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.All(viewModel.BackgroundPalette, color => Assert.IsType<System.Drawing.Color>(color));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Until_Windows_answers_the_startup_setting_stays_hidden()
|
||||
{
|
||||
using SettingsViewModel viewModel = Create();
|
||||
|
||||
Assert.False(viewModel.IsStartupAvailable);
|
||||
Assert.False(viewModel.CanChangeStartup);
|
||||
Assert.False(viewModel.IsStartupLocked);
|
||||
Assert.False(viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StartupState.Enabled, true, true, false, true)]
|
||||
[InlineData(StartupState.Disabled, true, true, false, false)]
|
||||
[InlineData(StartupState.DisabledByUser, true, false, true, false)]
|
||||
[InlineData(StartupState.DisabledByPolicy, true, false, true, false)]
|
||||
[InlineData(StartupState.EnabledByPolicy, true, false, true, true)]
|
||||
[InlineData(StartupState.Unavailable, false, false, false, false)]
|
||||
public async Task The_startup_state_decides_how_the_setting_looks(
|
||||
StartupState state, bool available, bool canChange, bool locked, bool enabled)
|
||||
{
|
||||
var startup = new FakeStartupService { State = state };
|
||||
using var viewModel = Create(startup: startup);
|
||||
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
Assert.Equal(available, viewModel.IsStartupAvailable);
|
||||
Assert.Equal(canChange, viewModel.CanChangeStartup);
|
||||
Assert.Equal(locked, viewModel.IsStartupLocked);
|
||||
Assert.Equal(enabled, viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_startup_setting_is_announced_after_Windows_answers()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Enabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
Assert.Contains(nameof(SettingsViewModel.RunAtStartup), changed);
|
||||
Assert.Contains(nameof(SettingsViewModel.IsStartupAvailable), changed);
|
||||
Assert.Contains(nameof(SettingsViewModel.CanChangeStartup), changed);
|
||||
Assert.Contains(nameof(SettingsViewModel.IsStartupLocked), changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabling_startup_reaches_Windows()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Disabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
viewModel.RunAtStartup = true;
|
||||
|
||||
await WaitForStartupRequests(startup, 1);
|
||||
Assert.Equal([true], startup.Requests);
|
||||
Assert.True(viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_startup_reaches_Windows()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Enabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
viewModel.RunAtStartup = false;
|
||||
|
||||
await WaitForStartupRequests(startup, 1);
|
||||
Assert.Equal([false], startup.Requests);
|
||||
Assert.False(viewModel.RunAtStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Setting_the_same_value_again_leaves_Windows_alone()
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Enabled };
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
viewModel.RunAtStartup = true;
|
||||
|
||||
Assert.Empty(startup.Requests);
|
||||
}
|
||||
|
||||
// A ban by the user is not for the app to argue with: the tick has to come back
|
||||
[Fact]
|
||||
public async Task A_refused_request_puts_the_tick_back()
|
||||
{
|
||||
var startup = new FakeStartupService
|
||||
{
|
||||
State = StartupState.Disabled,
|
||||
AnswerOnEnable = StartupState.DisabledByUser,
|
||||
};
|
||||
|
||||
using var viewModel = Create(startup: startup);
|
||||
await viewModel.InitializeAsync();
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.RunAtStartup = true;
|
||||
|
||||
await WaitForStartupRequests(startup, 1);
|
||||
|
||||
Assert.False(viewModel.RunAtStartup);
|
||||
Assert.True(viewModel.IsStartupLocked);
|
||||
Assert.Contains(nameof(SettingsViewModel.RunAtStartup), changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_unsubscribes_from_the_settings_and_the_language()
|
||||
{
|
||||
var settings = new AppSettings { Language = "en" };
|
||||
var localization = new FakeLocalizationService();
|
||||
SettingsViewModel viewModel = Create(settings, localization);
|
||||
|
||||
EnumOption<AppTheme> option = viewModel.Themes[0];
|
||||
string display = option.Display;
|
||||
|
||||
viewModel.Dispose();
|
||||
|
||||
settings.Language = "ru";
|
||||
|
||||
// Neither the interface language nor the option captions change any more
|
||||
Assert.Equal("en", localization.CurrentLanguage);
|
||||
Assert.Equal(display, option.Display);
|
||||
}
|
||||
|
||||
private static SettingsViewModel Create(
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null,
|
||||
IStartupService? startup = null) =>
|
||||
new(settings ?? new AppSettings(),
|
||||
localization ?? new FakeLocalizationService(),
|
||||
startup ?? new FakeStartupService(),
|
||||
Fake.Updates());
|
||||
|
||||
// The setting travels to Windows without being awaited: the window must not freeze
|
||||
private static async Task WaitForStartupRequests(FakeStartupService startup, int count)
|
||||
{
|
||||
for (int i = 0; i < 100 && startup.Requests.Count < count; i++)
|
||||
{
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
Assert.Equal(count, startup.Requests.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Settings.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The updates section of the settings window: what it shows at every step and
|
||||
/// what it asks of the service behind it.
|
||||
/// </summary>
|
||||
public sealed class UpdateViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Before_the_first_check_the_section_says_nothing()
|
||||
{
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService());
|
||||
|
||||
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
|
||||
Assert.False(viewModel.HasStatus);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsInstallOffered);
|
||||
Assert.True(viewModel.CanCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_update_found_is_offered_for_download()
|
||||
{
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Available, viewModel.Status);
|
||||
Assert.True(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsInstallOffered);
|
||||
Assert.Equal("https://host/releases/tag", viewModel.ReleaseUrl?.ToString());
|
||||
Assert.True(viewModel.IsReleaseLinkShown);
|
||||
Assert.Equal("en:UpdateAvailable", viewModel.StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_latest_version_installed_there_is_nothing_to_offer()
|
||||
{
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService());
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.UpToDate, viewModel.Status);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
Assert.False(viewModel.IsReleaseLinkShown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_by_the_button_says_when_it_did_not_work_out()
|
||||
{
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
|
||||
Assert.True(viewModel.HasStatus);
|
||||
}
|
||||
|
||||
// The app does not always start with a live network, and the user who never
|
||||
// asked about updates has no use for the complaint
|
||||
[Fact]
|
||||
public async Task A_check_at_startup_keeps_a_failure_to_itself()
|
||||
{
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(UpdateStatus.Idle, viewModel.Status);
|
||||
Assert.False(viewModel.HasStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_check_is_remembered_in_the_settings()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(settings.LastUpdateCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_that_did_not_work_out_is_not_remembered()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
var updates = new FakeUpdateService { Failure = new HttpRequestException("no network") };
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(settings.LastUpdateCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_recent_check_is_not_repeated_at_startup()
|
||||
{
|
||||
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_check_of_yesterday_is_repeated_at_startup()
|
||||
{
|
||||
var settings = new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow - TimeSpan.FromDays(2) };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_ban_on_checking_by_itself_is_obeyed()
|
||||
{
|
||||
var settings = new AppSettings { CheckForUpdates = false };
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel viewModel = Create(updates, settings);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
|
||||
// The button still works: the setting is about the app doing it on its own
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ban_on_checking_travels_to_the_settings()
|
||||
{
|
||||
var settings = new AppSettings { CheckForUpdates = true };
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService(), settings);
|
||||
|
||||
viewModel.CheckAutomatically = false;
|
||||
|
||||
Assert.False(settings.CheckForUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_package_from_Store_is_left_to_Store()
|
||||
{
|
||||
var updates = new FakeUpdateService { IsSupported = false, Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.StartAsync();
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(viewModel.IsSupported);
|
||||
Assert.Equal(0, updates.CheckCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_downloaded_package_is_offered_for_installation()
|
||||
{
|
||||
using TempFolder folder = new();
|
||||
string package = folder.File("CursorLang-2.0.0.0.msixbundle");
|
||||
await File.WriteAllTextAsync(package, "package", TestContext.Current.CancellationToken);
|
||||
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0"), PackagePath = package };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Ready, viewModel.Status);
|
||||
Assert.True(viewModel.IsInstallOffered);
|
||||
Assert.False(viewModel.IsDownloadOffered);
|
||||
|
||||
viewModel.InstallCommand.Execute(null);
|
||||
|
||||
Assert.Equal([package], updates.Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task While_the_package_is_downloading_the_section_shows_it()
|
||||
{
|
||||
var updates = new FakeUpdateService
|
||||
{
|
||||
Release = Release("2.0.0.0"),
|
||||
DownloadGate = new TaskCompletionSource(),
|
||||
};
|
||||
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
Task download = viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Downloading, viewModel.Status);
|
||||
Assert.True(viewModel.IsProgressShown);
|
||||
Assert.True(viewModel.IsBusy);
|
||||
Assert.False(viewModel.CanCheck);
|
||||
|
||||
updates.DownloadGate.SetResult();
|
||||
await download;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_download_that_did_not_work_out_is_told_about()
|
||||
{
|
||||
var updates = new FakeUpdateService { Release = Release("2.0.0.0") };
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
updates.Failure = new HttpRequestException("the connection dropped");
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(UpdateStatus.Failed, viewModel.Status);
|
||||
}
|
||||
|
||||
// The temp folder is cleared by Windows as it sees fit, and the app has no
|
||||
// business handing a file that is gone to the installer
|
||||
[Fact]
|
||||
public async Task A_package_gone_from_the_disk_is_offered_for_download_again()
|
||||
{
|
||||
var updates = new FakeUpdateService
|
||||
{
|
||||
Release = Release("2.0.0.0"),
|
||||
PackagePath = Path.Combine(Path.GetTempPath(), "CursorLang.Tests", "never-written.msixbundle"),
|
||||
};
|
||||
|
||||
using UpdateViewModel viewModel = Create(updates);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
await viewModel.DownloadCommand.ExecuteAsync(null);
|
||||
|
||||
viewModel.InstallCommand.Execute(null);
|
||||
|
||||
Assert.Empty(updates.Installed);
|
||||
Assert.Equal(UpdateStatus.Available, viewModel.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_status_is_written_in_the_chosen_language()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
|
||||
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
Assert.Equal("en:UpdateUpToDate", viewModel.StatusText);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
Assert.Equal("ru:UpdateUpToDate", viewModel.StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closing_unsubscribes_from_the_language()
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
UpdateViewModel viewModel = Create(new FakeUpdateService(), localization: localization);
|
||||
await viewModel.CheckCommand.ExecuteAsync(null);
|
||||
|
||||
List<string?> changed = [];
|
||||
viewModel.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
viewModel.Dispose();
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Empty(changed);
|
||||
}
|
||||
|
||||
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 UpdateViewModel Create(
|
||||
IUpdateService updates,
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null) =>
|
||||
new(updates,
|
||||
localization ?? new FakeLocalizationService(),
|
||||
settings ?? new AppSettings(),
|
||||
new UpdateOptions());
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.Views;
|
||||
using DrawingColor = System.Drawing.Color;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The binding converters: they decide what the settings window shows and what
|
||||
/// it keeps out of sight — and they are the border between the colours the
|
||||
/// settings hold, which are GDI ones, and the brushes WPF paints with.
|
||||
/// </summary>
|
||||
public sealed class ConvertersTests
|
||||
{
|
||||
private static readonly CultureInfo Culture = CultureInfo.InvariantCulture;
|
||||
|
||||
[Fact]
|
||||
public void A_match_with_the_single_listed_value_shows_the_element()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Visible,
|
||||
converter.Convert(PopupPlacementMode.AtCursor, typeof(Visibility), "AtCursor", Culture));
|
||||
}
|
||||
|
||||
// The anchor settings suit two placement modes at once
|
||||
[Fact]
|
||||
public void A_match_with_one_of_the_listed_values_shows_the_element()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Visible,
|
||||
converter.Convert(PopupPlacementMode.AtCaret, typeof(Visibility), "AtCursor, AtCaret", Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_match_hides_the_element()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Collapsed,
|
||||
converter.Convert(PopupPlacementMode.FixedPoint, typeof(Visibility), "AtCursor, AtCaret", Culture));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "AtCursor")]
|
||||
[InlineData(PopupPlacementMode.AtCursor, null)]
|
||||
[InlineData(PopupPlacementMode.AtCursor, "")]
|
||||
[InlineData(PopupPlacementMode.AtCursor, ",,")]
|
||||
[InlineData(null, null)]
|
||||
public void Without_a_value_or_without_a_list_the_element_is_hidden(object? value, string? parameter)
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(Visibility.Collapsed, converter.Convert(value, typeof(Visibility), parameter, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extra_spaces_in_the_list_do_not_get_in_the_way()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Visibility.Visible,
|
||||
converter.Convert(ScreenPosition.Center, typeof(Visibility), " Top , Center ", Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visibility_does_not_convert_back()
|
||||
{
|
||||
var converter = new EnumToVisibilityConverter();
|
||||
|
||||
Assert.Equal(
|
||||
Binding.DoNothing,
|
||||
converter.ConvertBack(Visibility.Visible, typeof(PopupPlacementMode), "AtCursor", Culture));
|
||||
}
|
||||
|
||||
// The alpha is not shown: transparency is a setting of its own
|
||||
[Fact]
|
||||
public void A_colour_is_shown_with_six_digits()
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal("#0A1B2C", converter.Convert(DrawingColor.FromArgb(0x0A, 0x1B, 0x2C), typeof(string), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_semi_transparent_colour_is_shown_without_its_alpha()
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal(
|
||||
"#102030",
|
||||
converter.Convert(DrawingColor.FromArgb(0x80, 0x10, 0x20, 0x30), typeof(string), null, Culture));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not a colour")]
|
||||
[InlineData(42)]
|
||||
public void Anything_that_is_not_a_colour_shows_as_an_empty_string(object? value)
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal(string.Empty, converter.Convert(value, typeof(string), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_colour_notation_does_not_convert_back()
|
||||
{
|
||||
var converter = new ColorToHexConverter();
|
||||
|
||||
Assert.Equal(Binding.DoNothing, converter.ConvertBack("#102030", typeof(DrawingColor), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_colour_turns_into_a_brush()
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
|
||||
|
||||
var brush = Assert.IsType<SolidColorBrush>(converter.Convert(color, typeof(Brush), null, Culture));
|
||||
|
||||
Assert.Equal(Color.FromArgb(color.A, color.R, color.G, color.B), brush.Color);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not a colour")]
|
||||
public void Anything_that_is_not_a_colour_turns_into_a_transparent_brush(object? value)
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
|
||||
Assert.Same(Brushes.Transparent, converter.Convert(value, typeof(Brush), null, Culture));
|
||||
}
|
||||
|
||||
// Picking a swatch in the list sends the colour back into the settings
|
||||
[Fact]
|
||||
public void A_brush_converts_back_into_a_colour()
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
DrawingColor color = DrawingColor.FromArgb(0x10, 0x20, 0x30);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(color.A, color.R, color.G, color.B));
|
||||
|
||||
Assert.Equal(color, converter.ConvertBack(brush, typeof(DrawingColor), null, Culture));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("not a brush")]
|
||||
public void Anything_that_is_not_a_brush_does_not_convert_back(object? value)
|
||||
{
|
||||
var converter = new ColorToBrushConverter();
|
||||
|
||||
Assert.Equal(Binding.DoNothing, converter.ConvertBack(value, typeof(DrawingColor), null, Culture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_converters_are_fit_for_bindings()
|
||||
{
|
||||
Assert.IsAssignableFrom<IValueConverter>(new EnumToVisibilityConverter());
|
||||
Assert.IsAssignableFrom<IValueConverter>(new ColorToHexConverter());
|
||||
Assert.IsAssignableFrom<IValueConverter>(new ColorToBrushConverter());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Settings.Views;
|
||||
using CursorLang.Tests.Shared;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window as a whole: the markup, the bindings and the hook-up
|
||||
/// to the theme.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window has to be shown for real: before the show WPF builds no element
|
||||
/// tree and computes no bindings. Full transparency keeps it out of sight.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_window_is_built_and_takes_its_data_from_the_view_model()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
var theme = new FakeThemeService();
|
||||
|
||||
Open(viewModel, theme, window =>
|
||||
{
|
||||
Assert.Same(viewModel, window.DataContext);
|
||||
Assert.Equal([window], theme.Registered);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_title_comes_from_the_interface_strings()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using SettingsViewModel viewModel = CreateViewModel(localization: localization);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Assert.Equal("en:SettingsTitle", window.Title);
|
||||
|
||||
// A language change goes over every binding to a string
|
||||
localization.CurrentLanguage = "ru";
|
||||
Assert.Equal("ru:SettingsTitle", window.Title);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_fits_its_height_to_its_content()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Assert.Equal(SizeToContent.Height, window.SizeToContent);
|
||||
Assert.Equal(ResizeMode.CanMinimize, window.ResizeMode);
|
||||
Assert.True(window.ActualHeight > 0);
|
||||
Assert.True(window.ActualWidth > 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The markup asks the view model for lists and palettes: if a name drifts
|
||||
// apart from the model, the binding silently shows an empty list
|
||||
[Fact]
|
||||
public void The_lists_in_the_window_are_filled()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
List<ComboBox> boxes = [.. FindAll<ComboBox>(window)];
|
||||
|
||||
Assert.NotEmpty(boxes);
|
||||
Assert.All(boxes, box => Assert.NotEmpty(box.Items));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lists_show_captions_in_the_chosen_language()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
// Languages are named in themselves, while the enum options are
|
||||
// named by strings from the resources: the latter are checked
|
||||
List<string> displays =
|
||||
[
|
||||
.. FindAll<ComboBox>(window)
|
||||
.SelectMany(box => box.Items.OfType<object>())
|
||||
.Where(item => item.GetType().Name.StartsWith("EnumOption", StringComparison.Ordinal))
|
||||
.Select(item => item.ToString() ?? string.Empty),
|
||||
];
|
||||
|
||||
Assert.NotEmpty(displays);
|
||||
Assert.All(displays, display => Assert.StartsWith("en:", display, StringComparison.Ordinal));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_shows_a_preview_of_the_tooltip()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings { FontSize = 33 };
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
Open(viewModel, window =>
|
||||
// The font size from the settings is visible right in the window
|
||||
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 33) < 0.001));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tooltip_colours_are_shown_as_swatches()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
// The chosen colour is shown as a swatch with a caption — in the
|
||||
// same notation the settings file uses
|
||||
System.Drawing.Color chosen = viewModel.BackgroundPalette[2];
|
||||
settings.BackgroundColor = chosen;
|
||||
|
||||
string expected = $"#{chosen.R:X2}{chosen.G:X2}{chosen.B:X2}";
|
||||
|
||||
Open(viewModel, window => Assert.Contains(
|
||||
FindAll<TextBlock>(window),
|
||||
text => text.Text.Equals(expected, StringComparison.OrdinalIgnoreCase)));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_startup_setting_hides_until_Windows_answers()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Assert.False(viewModel.IsStartupAvailable);
|
||||
Assert.All(FindStartupCheckBoxes(window), box => Assert.False(box.IsVisible));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_allowed_startup_shows_up_in_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.Disabled };
|
||||
using SettingsViewModel viewModel = CreateViewModel(startup: startup);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
viewModel.InitializeAsync().GetAwaiter().GetResult();
|
||||
window.UpdateLayout();
|
||||
|
||||
CheckBox box = Assert.Single(FindStartupCheckBoxes(window));
|
||||
Assert.True(box.IsVisible);
|
||||
Assert.True(box.IsEnabled);
|
||||
Assert.False(box.IsChecked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A ban by the user is not for the app to argue with: the tick is shown,
|
||||
// but it cannot be moved
|
||||
[Fact]
|
||||
public void A_startup_banned_by_Windows_is_shown_as_unavailable()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var startup = new FakeStartupService { State = StartupState.DisabledByUser };
|
||||
using SettingsViewModel viewModel = CreateViewModel(startup: startup);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
viewModel.InitializeAsync().GetAwaiter().GetResult();
|
||||
window.UpdateLayout();
|
||||
|
||||
CheckBox box = Assert.Single(FindStartupCheckBoxes(window));
|
||||
Assert.True(box.IsVisible);
|
||||
Assert.False(box.IsEnabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Caps_Lock_interception_is_toggled_by_a_tick()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var settings = new AppSettings { UseCapsLockHotkey = false };
|
||||
using SettingsViewModel viewModel = CreateViewModel(settings);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
CheckBox box = Assert.Single(FindCheckBoxesBoundTo(window, "Settings.UseCapsLockHotkey"));
|
||||
|
||||
box.IsChecked = true;
|
||||
|
||||
Assert.True(settings.UseCapsLockHotkey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_updates_are_checked_by_the_button_in_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var updates = new FakeUpdateService();
|
||||
using UpdateViewModel section = CreateUpdates(updates);
|
||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
Button check = Assert.Single(FindButtonsBoundTo(window, "Updates.CheckCommand"));
|
||||
|
||||
Assert.True(check.IsVisible);
|
||||
check.Command.Execute(null);
|
||||
|
||||
Assert.Equal(1, updates.CheckCalls);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// An app installed from the Store is updated by the Store
|
||||
[Fact]
|
||||
public void An_app_that_updates_itself_elsewhere_shows_no_updates_section()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using UpdateViewModel section = CreateUpdates(new FakeUpdateService { IsSupported = false });
|
||||
using SettingsViewModel viewModel = CreateViewModel(updates: section);
|
||||
|
||||
Open(viewModel, window =>
|
||||
Assert.All(FindButtonsBoundTo(window, "Updates.CheckCommand"), button =>
|
||||
Assert.False(button.IsVisible)));
|
||||
});
|
||||
}
|
||||
|
||||
// The application lives in the tray, and the settings window is a guest on the
|
||||
// screen: its close button puts it away rather than ends anything
|
||||
/// <summary>
|
||||
/// Closing the window closes it. The tray it used to hide into belongs to another
|
||||
/// process now, and there is nothing shared left to keep alive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Closing_the_window_really_closes_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
window.Close();
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(window.Show);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_hooks_up_to_the_placement()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
var placement = new MainWindowPlacement();
|
||||
|
||||
Open(viewModel, new FakeThemeService(), placement, window =>
|
||||
{
|
||||
// The placement works off the window creation event: the window
|
||||
// has to end up on a monitor rather than beyond its edges
|
||||
Assert.True(window.Left > -10_000);
|
||||
Assert.True(window.Top > -10_000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static SettingsViewModel CreateViewModel(
|
||||
AppSettings? settings = null,
|
||||
ILocalizationService? localization = null,
|
||||
IStartupService? startup = null,
|
||||
UpdateViewModel? updates = null) =>
|
||||
new(settings ?? new AppSettings(),
|
||||
localization ?? new FakeLocalizationService(),
|
||||
startup ?? new FakeStartupService(),
|
||||
updates ?? Fake.Updates());
|
||||
|
||||
private static void Open(SettingsViewModel viewModel, Action<MainWindow> check) =>
|
||||
Open(viewModel, new FakeThemeService(), new MainWindowPlacement(), check);
|
||||
|
||||
private static void Open(SettingsViewModel viewModel, IThemeService theme, Action<MainWindow> check) =>
|
||||
Open(viewModel, theme, new MainWindowPlacement(), check);
|
||||
|
||||
private static void Open(
|
||||
SettingsViewModel viewModel,
|
||||
IThemeService theme,
|
||||
MainWindowPlacement placement,
|
||||
Action<MainWindow> check)
|
||||
{
|
||||
var window = new MainWindow(viewModel, theme, placement)
|
||||
{
|
||||
// The window is needed alive, but not in sight
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
check(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private static UpdateViewModel CreateUpdates(IUpdateService updates) =>
|
||||
new(updates, new FakeLocalizationService(), new AppSettings(), new UpdateOptions());
|
||||
|
||||
private static IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
|
||||
FindAll<Button>(root).Where(button =>
|
||||
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
||||
|
||||
private static IEnumerable<CheckBox> FindStartupCheckBoxes(DependencyObject root) =>
|
||||
FindCheckBoxesBoundTo(root, nameof(SettingsViewModel.RunAtStartup));
|
||||
|
||||
// An element is found by what it is bound to: the markup gives them no names
|
||||
private static IEnumerable<CheckBox> FindCheckBoxesBoundTo(DependencyObject root, string path) =>
|
||||
FindAll<CheckBox>(root).Where(box =>
|
||||
BindingOperations.GetBinding(box, ToggleButton.IsCheckedProperty)?.Path.Path == path);
|
||||
|
||||
// Walking the element tree: the window markup is large, and things have to be searched for
|
||||
private static IEnumerable<TElement> FindAll<TElement>(DependencyObject root)
|
||||
where TElement : DependencyObject
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
DependencyObject child = VisualTreeHelper.GetChild(root, i);
|
||||
|
||||
if (child is TElement found)
|
||||
{
|
||||
yield return found;
|
||||
}
|
||||
|
||||
foreach (TElement nested in FindAll<TElement>(child))
|
||||
{
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Text.RegularExpressions;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window markup against the resource strings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The resources live in Core and the markup lives here, so the check that the two
|
||||
/// agree has to live here too. What the resources say among themselves — that every
|
||||
/// key is translated, that the placeholders match — is checked by StringsTests, next
|
||||
/// to the resources.
|
||||
///
|
||||
/// The markup is read as an embedded copy rather than off disk: a test that walks up
|
||||
/// the folder tree looking for a .xaml file breaks the moment anything moves.
|
||||
/// </remarks>
|
||||
public sealed partial class MarkupStringsTests
|
||||
{
|
||||
private static readonly ResourceManager Resources =
|
||||
new("CursorLang.Core.Resources.Strings", typeof(LocalizationService).Assembly);
|
||||
|
||||
/// <summary>
|
||||
/// Every key the settings window markup asks for has to exist in the
|
||||
/// resources: otherwise the user sees the key itself in its place.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_key_from_the_settings_window_markup_exists_in_the_resources()
|
||||
{
|
||||
HashSet<string> known = [.. NeutralKeys()];
|
||||
List<string> missing = [];
|
||||
|
||||
foreach (Match match in LocalizationBinding().Matches(ReadSettingsWindowMarkup()))
|
||||
{
|
||||
string key = match.Groups["key"].Value;
|
||||
if (!known.Contains(key))
|
||||
{
|
||||
missing.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
/// <summary>The markup does ask for strings — otherwise the check above means nothing.</summary>
|
||||
[Fact]
|
||||
public void The_settings_window_markup_asks_for_resource_strings()
|
||||
{
|
||||
Assert.NotEmpty(LocalizationBinding().Matches(ReadSettingsWindowMarkup()));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NeutralKeys()
|
||||
{
|
||||
ResourceSet set = Resources.GetResourceSet(CultureInfo.InvariantCulture, true, true)!;
|
||||
|
||||
foreach (DictionaryEntry entry in set)
|
||||
{
|
||||
yield return (string)entry.Key;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadSettingsWindowMarkup()
|
||||
{
|
||||
using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MainWindow.xaml")
|
||||
?? throw new InvalidOperationException("The settings window markup is not embedded in the test assembly");
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"Localization\[(?<key>\w+)\]")]
|
||||
private static partial Regex LocalizationBinding();
|
||||
}
|
||||
Reference in New Issue
Block a user