using System.Drawing; using System.Globalization; using System.Text.Json; using CursorLang.Core.Models; using CursorLang.Core.Services; using CursorLang.Core.Tests.Models; using CursorLang.Tests.Shared; namespace CursorLang.Core.Tests.Services; /// /// Keeping the settings in a file. Only the settings window writes, and it asks for /// that with TrackChanges; the agent loads the same file and never saves. Everything happens in a temporary folder: /// the tests have no business touching the user's own settings. /// public sealed class SettingsServiceTests { /// The deferred write delay in tests: half a second is not worth waiting for. private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(20); [Fact] public void Without_a_file_the_defaults_are_handed_out() { using var folder = new TempFolder(); AppSettings settings = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(AppTheme.System, settings.Theme); Assert.Equal(20, settings.Current.FontSize); } [Theory] [InlineData("ru", "ru")] [InlineData("ru-RU", "ru")] [InlineData("en-US", "en")] [InlineData("de-DE", "en")] [InlineData("fr", "en")] public void The_default_language_follows_the_language_of_Windows(string uiCulture, string expected) { using var folder = new TempFolder(); AppSettings settings = Pump.Run(() => { CultureInfo previous = CultureInfo.CurrentUICulture; try { CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(uiCulture); using SettingsService service = Create(folder); return service.Load(); } finally { CultureInfo.CurrentUICulture = previous; } }); Assert.Equal(expected, settings.Language); } [Fact] public void Saved_settings_are_read_back() { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); settings.Theme = AppTheme.Dark; settings.PlacementMode = PopupPlacementMode.AtCaret; settings.Current.FontSize = 42; settings.Current.BackgroundColor = Color.FromArgb(0x11, 0x22, 0x33); settings.UseCapsLockHotkey = true; service.Save(); }); AppSettings restored = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(42, restored.Current.FontSize); Assert.Equal(AppTheme.Dark, restored.Theme); Assert.Equal(PopupPlacementMode.AtCaret, restored.PlacementMode); Assert.Equal(Color.FromArgb(0x11, 0x22, 0x33), restored.Current.BackgroundColor); Assert.True(restored.UseCapsLockHotkey); } [Fact] public void The_settings_land_in_the_file_in_a_readable_form() { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); settings.Theme = AppTheme.Dark; settings.Current.BackgroundColor = Color.FromArgb(0x20, 0x20, 0x20); service.Save(); }); string json = File.ReadAllText(folder.File("settings.json")); // The theme as a word rather than a number; the colour in its usual notation Assert.Contains("\"Theme\": \"Dark\"", json, StringComparison.Ordinal); Assert.Contains("#FF202020", json, StringComparison.Ordinal); // And all of it across lines: the file is sometimes edited by hand Assert.Contains('\n', json); } [Fact] public void A_changed_setting_saves_itself() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); service.TrackChanges(); settings.Current.FontSize = 33; // Right after the edit there is nothing on disk yet: the write is deferred Assert.False(File.Exists(path)); Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer"); }); Assert.Contains("\"FontSize\": 33", File.ReadAllText(path), StringComparison.Ordinal); } // A slider changes its value continuously, and writing every move to disk is pointless [Fact] public void A_run_of_edits_defers_the_write_until_a_pause() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = new(path, folder.File("inherited.json"), TimeSpan.FromMilliseconds(150)); AppSettings settings = service.Load(); service.TrackChanges(); for (int i = 0; i < 10; i++) { settings.Current.Opacity = 0.5 + (i * 0.01); Assert.False(File.Exists(path)); Pump.Pause(TimeSpan.FromMilliseconds(10)); } Pump.WaitFor(() => File.Exists(path), "the write happened after the pause in edits"); }); } /// /// Asking to track changes before reading the file still tracks them. /// /// /// The settings window asks in exactly that order: its container hands out the /// service first and the settings only when something needs them. A version of this /// that quietly did nothing when the file had not been read yet left the window /// saving nothing at all — neither while it was open nor when it was closed. /// [Fact] public void Tracking_asked_for_before_the_file_is_read_still_saves() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = Create(folder); // Before Load, the way the settings window does it service.TrackChanges(); AppSettings settings = service.Load(); settings.Current.FontSize = 29; Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer"); }); Assert.Contains("\"FontSize\": 29", File.ReadAllText(path), StringComparison.Ordinal); } // Two reads would mean two instances, and the window would edit one while the // service saved the other [Fact] public void Reading_twice_hands_out_the_same_settings() { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); Assert.Same(service.Load(), service.Load()); }); } /// /// Re-reading pours the file into the instance everything is already bound to. /// /// /// This is the agent's whole side of the connection: the settings window writes and /// says so, and the agent calls this. Replacing the instance instead of filling it /// would leave the popup, the hook and the timers bound to the old one. /// [Fact] public void Re_reading_lands_in_the_settings_already_in_hand() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); File.WriteAllText(path, """{"AtCursor": {"FontSize": 31, "BackgroundColor": "#FF102030"}}"""); service.Reload(); Assert.Equal(31, settings.Current.FontSize); Assert.Equal(Color.FromArgb(0x10, 0x20, 0x30), settings.Current.BackgroundColor); }); } // A file that has gone missing or turned to nonsense leaves the settings alone: // showing the popup with yesterday's colours beats showing it with none [Fact] public void Re_reading_an_unreadable_file_keeps_what_was_already_there() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); settings.Current.FontSize = 44; File.WriteAllText(path, "not json at all"); service.Reload(); Assert.Equal(44, settings.Current.FontSize); }); } [Fact] public void Closing_the_service_saves_the_latest_edits() { using var folder = new TempFolder(); Pump.Run(() => { SettingsService service = Create(folder); AppSettings settings = service.Load(); service.TrackChanges(); settings.Current.FontSize = 27; service.Dispose(); }); Assert.Contains( "\"FontSize\": 27", File.ReadAllText(folder.File("settings.json")), StringComparison.Ordinal); } [Fact] public void After_closing_edits_no_longer_reach_the_disk() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { SettingsService service = Create(folder); AppSettings settings = service.Load(); service.TrackChanges(); service.Dispose(); string afterDispose = File.ReadAllText(path); settings.Current.FontSize = 99; Pump.Pause(TimeSpan.FromMilliseconds(60)); Assert.Equal(afterDispose, File.ReadAllText(path)); }); } [Fact] public void Settings_of_a_previous_install_are_taken_over() { using var folder = new TempFolder(); string inherited = folder.File("inherited.json"); string own = folder.File("settings.json"); File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}, "Language": "ru"}"""); AppSettings settings = Pump.Run(() => { using SettingsService service = new(own, inherited, SaveDelay); return service.Load(); }); Assert.Equal(31, settings.Current.FontSize); Assert.Equal("ru", settings.Language); // What was taken over is pinned to its new place at once rather than on the first edit Assert.True(File.Exists(own)); Assert.Contains("\"FontSize\": 31", File.ReadAllText(own), StringComparison.Ordinal); } // Both builds may be installed side by side: the other one keeps its settings [Fact] public void The_previous_install_does_not_lose_its_settings() { using var folder = new TempFolder(); string inherited = folder.File("inherited.json"); string original = """{"AtCursor": {"FontSize": 31}}"""; File.WriteAllText(inherited, original); Pump.Run(() => { using SettingsService service = new(folder.File("settings.json"), inherited, SaveDelay); _ = service.Load(); }); Assert.Equal(original, File.ReadAllText(inherited)); } [Fact] public void Own_settings_outweigh_those_of_a_previous_install() { using var folder = new TempFolder(); string own = folder.File("settings.json"); string inherited = folder.File("inherited.json"); File.WriteAllText(own, """{"AtCursor": {"FontSize": 12}}"""); File.WriteAllText(inherited, """{"AtCursor": {"FontSize": 31}}"""); AppSettings settings = Pump.Run(() => { using SettingsService service = new(own, inherited, SaveDelay); return service.Load(); }); Assert.Equal(12, settings.Current.FontSize); } // Outside a package both paths are the same, so there is nothing to take over [Fact] public void Without_a_package_no_settings_are_taken_over() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = new(path, path, SaveDelay); _ = service.Load(); // No file appeared: there was nothing to take over and nowhere to take it from Assert.False(File.Exists(path)); }); } [Fact] public void A_broken_settings_file_does_not_bring_the_app_down() { using var folder = new TempFolder(); File.WriteAllText(folder.File("settings.json"), "{this is not json"); AppSettings settings = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(20, settings.Current.FontSize); } [Fact] public void Settings_with_unknown_fields_are_still_read() { using var folder = new TempFolder(); File.WriteAllText(folder.File("settings.json"), """{"AtCursor": {"FontSize": 15}, "SomethingNew": true}"""); AppSettings settings = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(15, settings.Current.FontSize); } [Theory] [InlineData("\"#FF102030\"", 0x10, 0x20, 0x30)] [InlineData("\"#102030\"", 0x10, 0x20, 0x30)] [InlineData("\"Red\"", 0xFF, 0x00, 0x00)] public void A_colour_is_read_from_its_usual_notation(string stored, byte r, byte g, byte b) { using var folder = new TempFolder(); File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }"""); AppSettings settings = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(Color.FromArgb(r, g, b), settings.Current.BackgroundColor); } [Theory] [InlineData("\"\"")] [InlineData("\" \"")] [InlineData("\"not a colour\"")] public void An_unintelligible_colour_becomes_black(string stored) { using var folder = new TempFolder(); File.WriteAllText(folder.File("settings.json"), $$"""{"AtCursor": {"BackgroundColor": {{stored}} } }"""); AppSettings settings = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(Color.Black, settings.Current.BackgroundColor); } // The service creates the settings folder itself [Fact] public void The_settings_folder_is_created_on_write() { using var folder = new TempFolder(); string nested = Path.Combine(folder.Path, "a", "b", "settings.json"); Pump.Run(() => { using SettingsService service = new(nested, folder.File("inherited.json"), SaveDelay); _ = service.Load(); service.Save(); }); Assert.True(File.Exists(nested)); } // Settings are not the kind of thing worth bringing the app down for [Fact] public void A_path_that_cannot_be_written_does_not_bring_the_app_down() { using var folder = new TempFolder(); // A folder sits where the settings file should be: writing there will not work string path = folder.File("settings.json"); Directory.CreateDirectory(path); Pump.Run(() => { using SettingsService service = new(path, folder.File("inherited.json"), SaveDelay); AppSettings settings = service.Load(); settings.Current.FontSize = 18; service.Save(); }); Assert.True(Directory.Exists(path)); } [Fact] public void Saving_without_loading_writes_nothing() { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); service.Save(); }); Assert.False(File.Exists(folder.File("settings.json"))); } [Fact] public void Closing_without_loading_passes_without_consequence() { using var folder = new TempFolder(); Pump.Run(() => { SettingsService service = Create(folder); service.Dispose(); }); Assert.False(File.Exists(folder.File("settings.json"))); } [Fact] public void Every_setting_of_the_app_reaches_the_file() { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); _ = service.Load(); service.Save(); }); using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json"))); List stored = [.. document.RootElement.EnumerateObject().Select(property => property.Name)]; foreach (string name in AppSettingsTests.WritablePropertyNames()) { Assert.Contains(name, stored); } } // The modes are sections of their own, and every setting of a mode has to reach the // file inside its own section [Theory] [InlineData(nameof(AppSettings.AtCursor), typeof(CursorModeSettings))] [InlineData(nameof(AppSettings.AtCaret), typeof(CaretModeSettings))] [InlineData(nameof(AppSettings.FixedPoint), typeof(FixedPointModeSettings))] public void Every_setting_of_a_mode_reaches_its_section_of_the_file(string section, Type mode) { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); _ = service.Load(); service.Save(); }); using JsonDocument document = JsonDocument.Parse(File.ReadAllText(folder.File("settings.json"))); Assert.True(document.RootElement.TryGetProperty(section, out JsonElement stored)); foreach (string name in PopupModeSettingsTests.WritablePropertyNames(mode)) { Assert.True(stored.TryGetProperty(name, out _), $"{section}.{name}"); } } /// /// The modes are stored apart: what is set up in one is still there after a trip /// through the file and the other two. /// [Fact] public void Each_mode_keeps_its_own_settings() { using var folder = new TempFolder(); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); settings.AtCursor.Side = AnchorSide.TopLeft; settings.AtCursor.FontSize = 14; settings.AtCaret.Side = CaretSide.Left; settings.AtCaret.FontSize = 28; settings.AtCaret.ForegroundColor = Color.FromArgb(0x0A, 0x0B, 0x0C); settings.FixedPoint.Position = ScreenPosition.Top; settings.FixedPoint.Offset = 96; settings.FixedPoint.Opacity = 0.4; service.Save(); }); AppSettings restored = Pump.Run(() => { using SettingsService service = Create(folder); return service.Load(); }); Assert.Equal(AnchorSide.TopLeft, restored.AtCursor.Side); Assert.Equal(14, restored.AtCursor.FontSize); Assert.Equal(CaretSide.Left, restored.AtCaret.Side); Assert.Equal(28, restored.AtCaret.FontSize); Assert.Equal(Color.FromArgb(0x0A, 0x0B, 0x0C), restored.AtCaret.ForegroundColor); Assert.Equal(ScreenPosition.Top, restored.FixedPoint.Position); Assert.Equal(96, restored.FixedPoint.Offset); Assert.Equal(0.4, restored.FixedPoint.Opacity); // What was left alone stays at its default rather than following a neighbour Assert.Equal(0.9, restored.AtCursor.Opacity); } // A change inside a mode is a change of the settings: the file has to follow the // sliders of the look as well as the ones above them [Fact] public void A_change_inside_a_mode_saves_itself() { using var folder = new TempFolder(); string path = folder.File("settings.json"); Pump.Run(() => { using SettingsService service = Create(folder); AppSettings settings = service.Load(); service.TrackChanges(); settings.AtCaret.FontSize = 37; Pump.WaitFor(() => File.Exists(path), "the settings were written by the timer"); }); using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); Assert.Equal( 37, document.RootElement .GetProperty(nameof(AppSettings.AtCaret)) .GetProperty(nameof(PopupModeSettings.FontSize)) .GetDouble()); } // An ordinary run picks the storage place itself: a package keeps settings // of its own, a separate install keeps them in the user profile [Fact] public void The_storage_place_is_chosen_on_its_own() { Pump.Run(() => { // Nothing is read and nothing is written: only the fact that a path // gets chosen without error is under test using var service = new SettingsService(); }); } private static SettingsService Create(TempFolder folder) => new(folder.File("settings.json"), folder.File("inherited.json"), SaveDelay); }