550 lines
19 KiB
C#
550 lines
19 KiB
C#
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();
|
|
settings.Current.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));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The preview follows the mode: switching it shows the settings of the new one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The look is bound through the current mode, so a switch has to reach the window.
|
|
/// Bound to a mode by name, the sliders would keep showing the cursor mode whatever
|
|
/// was chosen above them.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_switch_of_the_mode_shows_the_settings_of_that_mode()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
var settings = new AppSettings();
|
|
settings.AtCursor.FontSize = 24;
|
|
settings.FixedPoint.FontSize = 56;
|
|
|
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
|
|
|
Open(viewModel, window =>
|
|
{
|
|
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 24) < 0.001);
|
|
|
|
settings.PlacementMode = PopupPlacementMode.FixedPoint;
|
|
window.UpdateLayout();
|
|
|
|
Assert.Contains(FindAll<TextBlock>(window), text => Math.Abs(text.FontSize - 56) < 0.001);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// In the middle of the monitor the offset from the edge cannot be set, but it stays
|
|
/// in place.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// There is no edge to stand off from there, so the setting goes grey. Taking the row
|
|
/// out of the section instead would move everything below it on every switch of the
|
|
/// place — the section is not to jump about.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void In_the_middle_of_the_monitor_the_offset_from_the_edge_goes_grey()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.FixedPoint };
|
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
|
|
|
Open(viewModel, window =>
|
|
{
|
|
Slider offset = Assert.Single(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"));
|
|
|
|
Assert.True(offset.IsVisible);
|
|
Assert.False(offset.IsEnabled);
|
|
|
|
// And it looks the part: the template draws from brushes that know
|
|
// nothing of the state, so the style has to fade it
|
|
Assert.True(offset.Opacity < 1);
|
|
|
|
settings.FixedPoint.Position = ScreenPosition.TopRight;
|
|
window.UpdateLayout();
|
|
|
|
Assert.True(offset.IsVisible);
|
|
Assert.True(offset.IsEnabled);
|
|
Assert.Equal(1, offset.Opacity);
|
|
|
|
settings.FixedPoint.Position = ScreenPosition.Center;
|
|
window.UpdateLayout();
|
|
|
|
Assert.True(offset.IsVisible);
|
|
Assert.False(offset.IsEnabled);
|
|
Assert.True(offset.Opacity < 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Another mode has no fixed point at all, and its offset has no place on screen
|
|
[Fact]
|
|
public void Away_from_the_fixed_point_its_offset_is_not_shown()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCursor };
|
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
|
|
|
Open(viewModel, window =>
|
|
Assert.All(FindSlidersBoundTo(window, "Settings.FixedPoint.Offset"), slider =>
|
|
Assert.False(slider.IsVisible)));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// What the look belongs to is explained in a tooltip next to the mode.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The sliders of the look show other numbers after a switch of the mode, and that
|
|
/// has to be explained. A line of text saying so would sit in the section forever,
|
|
/// while the question is asked once.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void The_window_explains_that_the_look_belongs_to_the_mode()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
using SettingsViewModel viewModel = CreateViewModel();
|
|
|
|
Open(viewModel, window =>
|
|
{
|
|
// The text of a tooltip is bound but not computed until the tooltip is
|
|
// shown, so what it is bound to is what gets checked
|
|
List<TextBlock> hints =
|
|
[
|
|
.. FindAll<TextBlock>(window)
|
|
.Where(text => text.ToolTip is ToolTip tip &&
|
|
tip.Content is TextBlock content &&
|
|
PathOf(content, TextBlock.TextProperty) == "Localization[PopupLookPerModeHint]"),
|
|
];
|
|
|
|
TextBlock link = Assert.Single(hints);
|
|
|
|
Assert.Equal("en:MoreInfoLink", link.Text);
|
|
Assert.True(link.IsVisible);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Beside the caret and nowhere else: the list offers the two sides it has
|
|
[Fact]
|
|
public void The_caret_is_offered_two_sides_in_the_window()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret };
|
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
|
|
|
Open(viewModel, window =>
|
|
{
|
|
ComboBox sides = Assert.Single(
|
|
FindAll<ComboBox>(window),
|
|
box => BindingOperations.GetBinding(box, Selector.SelectedValueProperty)?.Path.Path
|
|
== "Settings.AtCaret.Side");
|
|
|
|
Assert.Equal(2, sides.Items.Count);
|
|
|
|
sides.SelectedValue = CaretSide.Left;
|
|
|
|
Assert.Equal(CaretSide.Left, settings.AtCaret.Side);
|
|
});
|
|
});
|
|
}
|
|
|
|
// The sliders of the look write into the mode chosen above them
|
|
[Fact]
|
|
public void The_look_is_edited_in_the_mode_that_is_chosen()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
var settings = new AppSettings { PlacementMode = PopupPlacementMode.AtCaret };
|
|
using SettingsViewModel viewModel = CreateViewModel(settings);
|
|
|
|
Open(viewModel, window =>
|
|
{
|
|
Slider slider = Assert.Single(FindSlidersBoundTo(window, "Settings.Current.FontSize"));
|
|
|
|
slider.Value = 44;
|
|
|
|
Assert.Equal(44, settings.AtCaret.FontSize);
|
|
Assert.Equal(20, settings.AtCursor.FontSize);
|
|
});
|
|
});
|
|
}
|
|
|
|
[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.Current.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);
|
|
});
|
|
});
|
|
}
|
|
|
|
// The version is nowhere else in the window, so the title has to carry it
|
|
[Fact]
|
|
public void The_title_of_the_window_shows_the_version()
|
|
{
|
|
Sta.Run(() =>
|
|
{
|
|
var localization = new FakeLocalizationService();
|
|
localization.Strings["SettingsTitle"] = "CursorLang {0} — Settings";
|
|
|
|
using SettingsViewModel viewModel = CreateViewModel(
|
|
localization: localization,
|
|
version: new Version(1, 2, 3, 0));
|
|
|
|
Open(viewModel, window => Assert.Equal("CursorLang 1.2.3 — Settings", window.Title));
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
Version? version = null) =>
|
|
new(settings ?? new AppSettings(),
|
|
localization ?? new FakeLocalizationService(),
|
|
startup ?? new FakeStartupService(),
|
|
version ?? new Version(1, 0, 0, 0));
|
|
|
|
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 IEnumerable<Button> FindButtonsBoundTo(DependencyObject root, string path) =>
|
|
FindAll<Button>(root).Where(button =>
|
|
BindingOperations.GetBinding(button, ButtonBase.CommandProperty)?.Path.Path == path);
|
|
|
|
private static string? PathOf(DependencyObject element, DependencyProperty property) =>
|
|
BindingOperations.GetBinding(element, property)?.Path.Path;
|
|
|
|
private static IEnumerable<Slider> FindSlidersBoundTo(DependencyObject root, string path) =>
|
|
FindAll<Slider>(root).Where(slider =>
|
|
BindingOperations.GetBinding(slider, RangeBase.ValueProperty)?.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;
|
|
}
|
|
}
|
|
}
|
|
}
|