Files
james.maesandClaude Fable 5.2 1bf7c8132d
node / node (push) Successful in 13s
build / scripts (push) Successful in 13s
windows / windows (push) Successful in 1m42s
build / windows (push) Successful in 1m42s
fix(settings): keep stored preferences when the file is shared or unreadable (#47)
Settings changes are applied to the latest stored settings under a cross-process lock and renamed into place in one step, so two instances no longer erase each other's changes and a file that cannot be read is never replaced by defaults. Invalid content is repaired per property with a copy of the original kept aside. ADR-0006 records the file protocol.

Closes #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 01:12:56 +00:00

125 lines
5.4 KiB
C#

/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Editors;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Proves bounded named editor settings, stable migration/order and one literal parent-folder argument.</summary>
public sealed class EditorConfigurationTests
{
/// <summary>Corrupt labels, paths and identities cannot become a native launch choice.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("bad\nname")]
public void Sanitize_InvalidNames_RejectsChoice(string name)
{
var editor = new EditorConfiguration(Guid.NewGuid(), name, @"C:\Editor\editor.exe");
Assert.False(EditorConfiguration.IsValid(editor));
Assert.Empty(EditorConfiguration.Sanitize([editor]));
}
/// <summary>Valid order and folder modes survive sanitation; duplicate/empty identities and invalid choices are discarded.</summary>
[Fact]
public void Sanitize_StableIdentityAndOrder_PreservesValidChoices()
{
var first = new EditorConfiguration(Guid.NewGuid(), " File editor ", @"C:\Editor\editor.exe");
var second = new EditorConfiguration(Guid.NewGuid(), "Folder editor", @"C:\Editor\other.exe", true);
EditorConfiguration[] choices = EditorConfiguration.Sanitize([null, first, first, second,
first with { Id = Guid.Empty }, first with { Id = Guid.NewGuid(), Name = new string('x', 81) },
first with { Id = Guid.NewGuid(), Executable = "editor.cmd" }, first with { Id = Guid.NewGuid(), Name = null! }]);
Assert.Equal(new[] { first.Id, second.Id }, choices.Select(editor => editor.Id));
Assert.Equal("File editor", choices[0].Name);
Assert.True(choices[1].OpensFolder);
Assert.Empty(EditorConfiguration.Sanitize(null));
Assert.False(EditorConfiguration.IsValid(null));
}
/// <summary>Large saved lists are bounded before being exposed as native commands.</summary>
[Fact]
public void Sanitize_TooManyEditors_BoundsList()
{
EditorConfiguration[] choices = EditorConfiguration.Sanitize(Enumerable.Range(0, 40).Select(index => new EditorConfiguration(Guid.NewGuid(), "Editor " + index, @"C:\Editor\editor.exe")));
Assert.Equal(EditorConfiguration.MaximumEditors, choices.Length);
Assert.Equal("Editor 31", choices[^1].Name);
}
/// <summary>Existing single-path settings migrate once; repeated sanitation preserves IDs and deletion does not resurrect a choice.</summary>
[Fact]
public void Sanitize_LegacyPath_MigratesStableChoiceAndAllowsRemoval()
{
var settings = new AppSettings { ExternalEditor = @"C:\Editor\editor.exe", ConfiguredEditors = null! };
settings.Sanitize();
EditorConfiguration choice = Assert.Single(settings.ConfiguredEditors);
Assert.NotEqual(Guid.Empty, choice.Id);
Assert.Equal("Editor", choice.Name);
Assert.False(choice.OpensFolder);
Assert.Equal(settings.ExternalEditor, choice.Executable);
settings.Sanitize();
Assert.Equal(choice.Id, Assert.Single(settings.ConfiguredEditors).Id);
settings.ConfiguredEditors = [];
settings.ExternalEditor = "";
settings.Sanitize();
Assert.Empty(settings.ConfiguredEditors);
Assert.Empty(settings.ExternalEditor);
}
/// <summary>Atomic persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
[Fact]
public void SaveAndLoad_EditorList_PreservesIdentityOrderAndMode()
{
using var temporary = new TempDirectory();
var store = new AppSettingsStore(temporary.File("settings.json"));
var first = new EditorConfiguration(Guid.NewGuid(), "Folder editor", @"C:\Editor\folder.exe", true);
var second = new EditorConfiguration(Guid.NewGuid(), "File editor", @"C:\Editor\file.exe");
Assert.NotNull(store.Update(_ => new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
AppSettings loaded = store.Load();
Assert.Equal(new[] { first, second }, loaded.ConfiguredEditors);
Assert.Equal(first.Executable, loaded.ExternalEditor);
Assert.NotNull(store.Update(settings =>
{
settings.ConfiguredEditors = [];
settings.ExternalEditor = "";
return settings;
}));
Assert.Empty(store.Load().ConfiguredEditors);
}
/// <summary>Folder mode supplies one literal parent directory, preserving punctuation and Unicode without shell execution.</summary>
[Theory]
[InlineData(@"C:\日本語 & $HOME\notes.md", @"C:\日本語 & $HOME")]
[InlineData(@"C:\notes.md", @"C:\")]
[InlineData(@"\\server\share\notes.md", @"\\server\share")]
public void Create_FolderMode_PassesOneLiteralParent(string document, string parent)
{
System.Diagnostics.ProcessStartInfo start = EditorLaunch.Create(@"C:\Editor\editor.exe", document, opensFolder: true);
Assert.Equal(parent, Assert.Single(start.ArgumentList));
Assert.Equal(parent, start.WorkingDirectory);
Assert.False(start.UseShellExecute);
Assert.Empty(start.Arguments);
}
/// <summary>A root directory cannot masquerade as a file when deriving its parent.</summary>
[Theory]
[InlineData(@"C:\")]
[InlineData(@"\\server\share\")]
public void Create_DirectoryRoot_RejectsDocument(string document)
=> Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", document, opensFolder: true));
}