## What Brings AppKit into conformance with [KofTwentyTwo/standards](https://github.com/KofTwentyTwo/standards). Reviewable commit by commit: | Commit | Change | | --- | --- | | build | Quality gate from `configs/dotnet` (analyzers incl. Meziantou, warnings as errors), `packages.lock.json` per project, locked-mode restore | | style | Kingsrook layout and LF line endings, produced mechanically by `dotnet format` (formatting only) | | docs | SPDX license header on every C# file | | refactor | Real code changes: analyzer findings fixed (one type per file, regex timeouts, async void, …) and header comments on every type and member | | ci | Old workflows replaced by callers of the shared standards workflows (PR gates, security, CodeQL, .NET CI, NuGet release with SBOM + attestation + trusted publishing), Scorecard | | docs | README, CONTRIBUTING (GitHub Flow, DCO, signing), SECURITY, CODEOWNERS, templates, Dependabot, threat model, ADRs, RELEASING | Also includes `dev`'s CI fix, so it supersedes #1; the `dev` branch goes away under GitHub Flow. ## Why AppKit is the first Product repository to adopt the standards. Static conformance before: 16 FAIL; after: 0 FAIL, 0 WARN (the remaining UNKNOWN rows are GitHub settings and rulesets, applied separately). ## How tested Locally with `CI=true` (lock files enforced): locked restore x64 and ARM64; zero-warning x64 build; 115 unit tests with 100% line coverage of `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`; 12 FlaUI UI tests (WinUI + WPF samples); `dotnet format --verify-no-changes --severity warn` clean; pack produces 4 packages + symbols; actionlint, zizmor (pedantic), gitleaks clean. The UI tests caught a regression introduced while fixing MA0147 (the WPF splash stopped dismissing because no UI context existed yet); fixed by starting the countdown through the dispatcher in both packages. ## Notes - `ci.yml` and `release.yml` are pinned to KofTwentyTwo/standards#3's head commit; they are repinned after that PR merges. - This pull request's new check names become required checks once the repository's rulesets are updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: James Maes <james@kof22.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
7.7 KiB
C#
245 lines
7.7 KiB
C#
/*
|
|
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
using System.Text.Json.Serialization;
|
|
using KofTwentyTwo.AppKit.Settings;
|
|
|
|
|
|
namespace KofTwentyTwo.AppKit.Tests;
|
|
|
|
/// <summary>An app's settings, the way apps are meant to extend <see cref="ShellSettings"/>.</summary>
|
|
public sealed class SampleSettings : ShellSettings
|
|
{
|
|
public int Concurrency { get; set; } = 8;
|
|
|
|
|
|
|
|
/// <summary>Repairs the shell settings, then clamps the app's own value.</summary>
|
|
public override void Sanitize()
|
|
{
|
|
base.Sanitize();
|
|
Concurrency = Math.Clamp(Concurrency, 1, 64);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>A settings type that does not sanitize.</summary>
|
|
public sealed class PlainSettings
|
|
{
|
|
public string Name { get; set; } = "default";
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Source-generated serializer for the test settings types.</summary>
|
|
[JsonSourceGenerationOptions(WriteIndented = true)]
|
|
[JsonSerializable(typeof(SampleSettings))]
|
|
[JsonSerializable(typeof(PlainSettings))]
|
|
internal sealed partial class TestSettingsContext : JsonSerializerContext
|
|
{
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Tests for loading and saving settings, including corrupt files and failed writes.</summary>
|
|
public sealed class SettingsStoreTests : IDisposable
|
|
{
|
|
private readonly TempDirectory _temp = new();
|
|
|
|
|
|
|
|
/// <summary>Deletes the test's temporary folder.</summary>
|
|
public void Dispose() => _temp.Dispose();
|
|
|
|
|
|
|
|
/// <summary>A store for the sample settings, by default in a nested folder that does not exist yet.</summary>
|
|
private SettingsStore<SampleSettings> Store(string? path = null)
|
|
=> new(path ?? _temp.File(Path.Combine("nested", "settings.json")), TestSettingsContext.Default.SampleSettings);
|
|
|
|
|
|
|
|
/// <summary>Load: missing file returns defaults.</summary>
|
|
[Fact]
|
|
public void Load_MissingFile_ReturnsDefaults()
|
|
{
|
|
SampleSettings settings = Store().Load();
|
|
Assert.Equal(8, settings.Concurrency);
|
|
Assert.Equal(AppTheme.System, settings.ThemeKind);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>SaveThenLoad: round trips.</summary>
|
|
[Fact]
|
|
public void SaveThenLoad_RoundTrips()
|
|
{
|
|
SettingsStore<SampleSettings> store = Store();
|
|
var settings = new SampleSettings { Concurrency = 12, ThemeKind = AppTheme.Dark, ShowSplashScreen = false, SplashMilliseconds = 1000, CheckForUpdatesOnStartup = false };
|
|
Assert.True(store.Save(settings));
|
|
Assert.False(File.Exists(store.Path + ".tmp"));
|
|
|
|
SampleSettings loaded = store.Load();
|
|
Assert.Equal(12, loaded.Concurrency);
|
|
Assert.Equal(AppTheme.Dark, loaded.ThemeKind);
|
|
Assert.Equal("Dark", loaded.Theme);
|
|
Assert.False(loaded.ShowSplashScreen);
|
|
Assert.Equal(1000, loaded.SplashMilliseconds);
|
|
Assert.False(loaded.CheckForUpdatesOnStartup);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Load: corrupt or null file returns defaults.</summary>
|
|
[Theory]
|
|
[InlineData("{ not json")]
|
|
[InlineData("null")]
|
|
public void Load_CorruptOrNullFile_ReturnsDefaults(string content)
|
|
{
|
|
SettingsStore<SampleSettings> store = Store();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(store.Path)!);
|
|
File.WriteAllText(store.Path, content);
|
|
Assert.Equal(8, store.Load().Concurrency);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Load: sanitizes out of range values.</summary>
|
|
[Fact]
|
|
public void Load_SanitizesOutOfRangeValues()
|
|
{
|
|
SettingsStore<SampleSettings> store = Store();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(store.Path)!);
|
|
File.WriteAllText(store.Path, """{ "Theme": "Purple", "SplashMilliseconds": 99999999, "Concurrency": 500 }""");
|
|
SampleSettings loaded = store.Load();
|
|
Assert.Equal("System", loaded.Theme);
|
|
Assert.Equal(ShellSettings.MaxSplashMilliseconds, loaded.SplashMilliseconds);
|
|
Assert.Equal(64, loaded.Concurrency);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Save: failure returns false.</summary>
|
|
[Fact]
|
|
public void Save_FailureReturnsFalse()
|
|
{
|
|
string notADirectory = _temp.File("file");
|
|
File.WriteAllText(notADirectory, "");
|
|
Assert.False(Store(Path.Combine(notADirectory, "settings.json")).Save(new SampleSettings()));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Save: swap fails cleans up the temporary file.</summary>
|
|
[Fact]
|
|
public void Save_SwapFails_CleansUpTheTemporaryFile()
|
|
{
|
|
SettingsStore<SampleSettings> store = Store();
|
|
Directory.CreateDirectory(store.Path); // the rename target is a directory
|
|
Assert.False(store.Save(new SampleSettings()));
|
|
Assert.False(File.Exists(store.Path + ".tmp"));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Save: temporary path blocked returns false and leaves original.</summary>
|
|
[Fact]
|
|
public void Save_TemporaryPathBlocked_ReturnsFalseAndLeavesOriginal()
|
|
{
|
|
SettingsStore<SampleSettings> store = Store();
|
|
Assert.True(store.Save(new SampleSettings { Concurrency = 3 }));
|
|
// A read-only leftover on the temp name: the write fails and so does the cleanup.
|
|
string temporary = store.Path + ".tmp";
|
|
File.WriteAllText(temporary, "stale");
|
|
File.SetAttributes(temporary, FileAttributes.ReadOnly);
|
|
try
|
|
{
|
|
Assert.False(store.Save(new SampleSettings { Concurrency = 4 }));
|
|
Assert.Equal(3, store.Load().Concurrency);
|
|
}
|
|
finally
|
|
{
|
|
File.SetAttributes(temporary, FileAttributes.Normal);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Save: bare file name writes relative to current directory.</summary>
|
|
[Fact]
|
|
public void Save_BareFileName_WritesRelativeToCurrentDirectory()
|
|
{
|
|
string name = $"appkit-{Guid.NewGuid():N}.json";
|
|
try
|
|
{
|
|
Assert.True(new SettingsStore<PlainSettings>(name, TestSettingsContext.Default.PlainSettings).Save(new PlainSettings { Name = "n" }));
|
|
Assert.Equal("n", new SettingsStore<PlainSettings>(name, TestSettingsContext.Default.PlainSettings).Load().Name);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(name);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Constructor: and save validate arguments.</summary>
|
|
[Fact]
|
|
public void Constructor_And_Save_ValidateArguments()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new SettingsStore<PlainSettings>(" ", TestSettingsContext.Default.PlainSettings));
|
|
Assert.Throws<ArgumentNullException>(() => new SettingsStore<PlainSettings>("a.json", null!));
|
|
Assert.Throws<ArgumentNullException>(() => Store().Save(null!));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Tests for the shared shell settings and their repair rules.</summary>
|
|
public class ShellSettingsTests
|
|
{
|
|
/// <summary>ThemeKind: tolerates bad stored values.</summary>
|
|
[Theory]
|
|
[InlineData("Light", AppTheme.Light)]
|
|
[InlineData("Dark", AppTheme.Dark)]
|
|
[InlineData("System", AppTheme.System)]
|
|
[InlineData("dark", AppTheme.System)]
|
|
[InlineData("42", AppTheme.System)]
|
|
[InlineData(null, AppTheme.System)]
|
|
public void ThemeKind_ToleratesBadStoredValues(string? stored, AppTheme expected)
|
|
{
|
|
Assert.Equal(expected, new ShellSettings { Theme = stored! }.ThemeKind);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Sanitize: clamps splash.</summary>
|
|
[Theory]
|
|
[InlineData(0, ShellSettings.DefaultSplashMilliseconds)]
|
|
[InlineData(1, ShellSettings.MinSplashMilliseconds)]
|
|
[InlineData(1500, 1500)]
|
|
[InlineData(int.MaxValue, ShellSettings.MaxSplashMilliseconds)]
|
|
public void Sanitize_ClampsSplash(int stored, int expected)
|
|
{
|
|
var settings = new ShellSettings { SplashMilliseconds = stored, Theme = null! };
|
|
settings.Sanitize();
|
|
Assert.Equal(expected, settings.SplashMilliseconds);
|
|
Assert.Equal("System", settings.Theme);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Fresh settings show the splash, check for updates, and use the default splash time.</summary>
|
|
[Fact]
|
|
public void Defaults()
|
|
{
|
|
var settings = new ShellSettings();
|
|
Assert.True(settings.ShowSplashScreen);
|
|
Assert.True(settings.CheckForUpdatesOnStartup);
|
|
Assert.Equal(ShellSettings.DefaultSplashMilliseconds, settings.SplashMilliseconds);
|
|
}
|
|
}
|