using System.ComponentModel; using System.Drawing; using System.Reflection; using System.Text.Json; using CursorLang.Core.Models; namespace CursorLang.Core.Tests.Models; public sealed class AppSettingsTests { [Fact] public void Default_values_describe_a_tooltip_at_the_cursor() { var settings = new AppSettings(); Assert.Equal("en", settings.Language); Assert.Equal(AppTheme.System, settings.Theme); Assert.Equal(PopupPlacementMode.AtCursor, settings.PlacementMode); Assert.Equal(500, settings.DurationMilliseconds); Assert.Equal(300, settings.CapsLockHoldMilliseconds); } [Fact] public void Every_mode_starts_out_looking_the_same() { var settings = new AppSettings(); Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side); Assert.Equal(16, settings.AtCursor.Offset); Assert.Equal(CaretSide.Right, settings.AtCaret.Side); Assert.Equal(16, settings.AtCaret.Offset); // The middle of the monitor, where there is no edge to stand off from Assert.Equal(ScreenPosition.Center, settings.FixedPoint.Position); Assert.Equal(0, settings.FixedPoint.Offset); PopupModeSettings[] modes = [settings.AtCursor, settings.AtCaret, settings.FixedPoint]; Assert.All(modes, mode => { Assert.Equal(20, mode.FontSize); Assert.Equal(0.9, mode.Opacity); Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), mode.BackgroundColor); Assert.Equal(Color.FromArgb(0xFF, 0xFF, 0xFF), mode.ForegroundColor); }); // Three modes and three sets of settings: none of them is shared Assert.Equal(3, modes.Distinct().Count()); } [Theory] [InlineData(PopupPlacementMode.AtCursor)] [InlineData(PopupPlacementMode.AtCaret)] [InlineData(PopupPlacementMode.FixedPoint)] public void The_chosen_mode_is_the_one_handed_out(PopupPlacementMode mode) { var settings = new AppSettings { PlacementMode = mode }; PopupModeSettings expected = mode switch { PopupPlacementMode.AtCaret => settings.AtCaret, PopupPlacementMode.FixedPoint => settings.FixedPoint, _ => settings.AtCursor, }; Assert.Same(expected, settings.Current); } /// /// The settings of a mode are its own: setting one up leaves the others alone. /// /// /// This is the whole point of keeping them per mode. A look shared by the modes /// meant setting it up again after every switch, and switching modes to see what /// they do undid what had just been set up. /// [Fact] public void Setting_one_mode_up_leaves_the_others_where_they_were() { var settings = new AppSettings(); settings.AtCaret.FontSize = 12; settings.AtCaret.Side = CaretSide.Left; settings.AtCaret.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33); Assert.Equal(20, settings.AtCursor.FontSize); Assert.Equal(AnchorSide.BottomRight, settings.AtCursor.Side); Assert.Equal(20, settings.FixedPoint.FontSize); Assert.Equal(Color.FromArgb(0x20, 0x20, 0x20), settings.FixedPoint.BackgroundColor); } // The mode in force decides what the look means, so a switch of it is a change of // everything the window shows through the current mode [Fact] public void A_switch_of_the_mode_is_announced_as_a_change_of_the_current_one() { var settings = new AppSettings(); List changed = []; settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName); settings.PlacementMode = PopupPlacementMode.FixedPoint; Assert.Contains(nameof(AppSettings.PlacementMode), changed); Assert.Contains(nameof(AppSettings.Current), changed); } /// /// A change inside a mode is passed on as a change of the settings. /// /// /// The service that writes the file listens to the settings alone. Without this, a /// font size dragged in the window would never reach the disk. /// [Fact] public void A_change_inside_a_mode_is_announced_by_the_settings() { var settings = new AppSettings(); List changed = []; settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName); settings.AtCaret.FontSize = 42; settings.FixedPoint.Position = ScreenPosition.Bottom; settings.AtCursor.Offset = 8; Assert.Contains("AtCaret.FontSize", changed); Assert.Contains("FixedPoint.Position", changed); Assert.Contains("AtCursor.Offset", changed); } // The app must not change how the system behaves until it is asked to [Fact] public void Caps_Lock_interception_is_off_by_default() { Assert.False(new AppSettings().UseCapsLockHotkey); } [Fact] public void The_time_on_screen_is_derived_from_milliseconds() { var settings = new AppSettings { DurationMilliseconds = 1250 }; Assert.Equal(TimeSpan.FromMilliseconds(1250), settings.Duration); } [Fact] public void The_hold_threshold_is_derived_from_milliseconds() { var settings = new AppSettings { CapsLockHoldMilliseconds = 400 }; Assert.Equal(TimeSpan.FromMilliseconds(400), settings.CapsLockHoldDelay); } [Theory] [MemberData(nameof(WritableProperties))] public void A_changed_setting_is_announced_to_subscribers(string propertyName) { var settings = new AppSettings(); List changed = []; settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName); SetDifferentValue(settings, propertyName); Assert.Contains(propertyName, changed); } [Theory] [MemberData(nameof(WritableProperties))] public void Writing_the_same_value_leaves_subscribers_alone(string propertyName) { var settings = new AppSettings(); PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!; object? value = property.GetValue(settings); List changed = []; settings.PropertyChanged += (_, e) => changed.Add(e.PropertyName); property.SetValue(settings, value); Assert.Empty(changed); } /// /// Pouring another instance in fills the modes in place rather than replacing them. /// /// /// This is the agent's side of the connection: the fresh values arrive as a freshly /// parsed instance. Replacing the mode objects would leave the popup bound to the /// old ones. /// [Fact] public void Taking_other_settings_over_fills_the_modes_that_are_already_there() { var settings = new AppSettings(); CursorModeSettings cursor = settings.AtCursor; var other = new AppSettings { Language = "ru", Theme = AppTheme.Dark, PlacementMode = PopupPlacementMode.FixedPoint, DurationMilliseconds = 900, UseCapsLockHotkey = true, CapsLockHoldMilliseconds = 450, }; other.AtCursor.Side = AnchorSide.TopLeft; other.AtCursor.Offset = 5; other.AtCursor.FontSize = 11; other.AtCaret.Side = CaretSide.Left; other.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C); other.FixedPoint.Position = ScreenPosition.Top; other.FixedPoint.Offset = 64; settings.CopyFrom(other); Assert.Same(cursor, settings.AtCursor); Assert.Equal("ru", settings.Language); Assert.Equal(AppTheme.Dark, settings.Theme); Assert.Equal(PopupPlacementMode.FixedPoint, settings.PlacementMode); Assert.Equal(900, settings.DurationMilliseconds); Assert.True(settings.UseCapsLockHotkey); Assert.Equal(450, settings.CapsLockHoldMilliseconds); Assert.Equal(AnchorSide.TopLeft, settings.AtCursor.Side); Assert.Equal(5, settings.AtCursor.Offset); Assert.Equal(11, settings.AtCursor.FontSize); Assert.Equal(CaretSide.Left, settings.AtCaret.Side); Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), settings.AtCaret.ForegroundColor); Assert.Equal(ScreenPosition.Top, settings.FixedPoint.Position); Assert.Equal(64, settings.FixedPoint.Offset); } // Derived values and the modes handed out for convenience are computed from what // is stored and have no business being in the file themselves [Fact] public void Derived_values_stay_out_of_the_file() { using JsonDocument document = JsonSerializer.SerializeToDocument(new AppSettings()); List names = [.. document.RootElement.EnumerateObject().Select(property => property.Name)]; Assert.DoesNotContain(nameof(AppSettings.Duration), names); Assert.DoesNotContain(nameof(AppSettings.CapsLockHoldDelay), names); Assert.DoesNotContain(nameof(AppSettings.Current), names); // What they are derived from, on the other hand, has to be stored Assert.Contains(nameof(AppSettings.DurationMilliseconds), names); Assert.Contains(nameof(AppSettings.CapsLockHoldMilliseconds), names); Assert.Contains(nameof(AppSettings.AtCursor), names); Assert.Contains(nameof(AppSettings.AtCaret), names); Assert.Contains(nameof(AppSettings.FixedPoint), names); } [Fact] public void Settings_report_changes_as_INotifyPropertyChanged() { Assert.IsAssignableFrom(new AppSettings()); } public static TheoryData WritableProperties() { var data = new TheoryData(); foreach (string name in WritablePropertyNames()) { data.Add(name); } return data; } /// Names of the settings the user is able to change. /// /// The modes are handed out rather than assigned — they are filled in place — so /// they are not among these. What is inside them is checked by /// . /// internal static IEnumerable WritablePropertyNames() => typeof(AppSettings).GetProperties() .Where(property => property.CanWrite) .Select(property => property.Name); // A value guaranteed to differ from the current one: each kind of setting // has its own way of differing private static void SetDifferentValue(AppSettings settings, string propertyName) { PropertyInfo property = typeof(AppSettings).GetProperty(propertyName)!; object? current = property.GetValue(settings); object next = current switch { string text => text + "-other", double number => number + 1, bool flag => !flag, Color color => Color.FromArgb((byte)(color.R + 1), color.G, color.B), Enum value => NextEnumValue(value), _ => throw new NotSupportedException($"Unknown kind of setting: {property.PropertyType}"), }; property.SetValue(settings, next); } private static object NextEnumValue(Enum current) { Array values = Enum.GetValues(current.GetType()); foreach (object? value in values) { if (!Equals(value, current)) { return value!; } } throw new NotSupportedException($"{current.GetType()} has a single value"); } }