654 lines
21 KiB
C#
654 lines
21 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class SettingsServiceTests
|
|
{
|
|
/// <summary>The deferred write delay in tests: half a second is not worth waiting for.</summary>
|
|
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");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asking to track changes before reading the file still tracks them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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());
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-reading pours the file into the instance everything is already bound to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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<string> 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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The modes are stored apart: what is set up in one is still there after a trip
|
|
/// through the file and the other two.
|
|
/// </summary>
|
|
[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);
|
|
}
|