lightweight variant (#1)

Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
@@ -0,0 +1,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();
}