## 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>
315 lines
9.2 KiB
C#
315 lines
9.2 KiB
C#
/*
|
|
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
using System.Reflection;
|
|
using System.Reflection.Emit;
|
|
|
|
|
|
namespace KofTwentyTwo.AppKit.Tests;
|
|
|
|
/// <summary>Tests for app identity: id validation, defaults, and derived names.</summary>
|
|
public class AppInfoTests
|
|
{
|
|
/// <summary>Id: accepts valid ids.</summary>
|
|
[Theory]
|
|
[InlineData("gclo")]
|
|
[InlineData("my-app-2")]
|
|
[InlineData("9lives")]
|
|
public void Id_AcceptsValidIds(string id)
|
|
{
|
|
var app = new AppInfo { Id = id, DisplayName = "X" };
|
|
Assert.Equal(id, app.Id);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Id: rejects invalid ids.</summary>
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("Gclo")]
|
|
[InlineData("-app")]
|
|
[InlineData("my app")]
|
|
[InlineData("my_app")]
|
|
[InlineData(null)]
|
|
public void Id_RejectsInvalidIds(string? id)
|
|
{
|
|
ArgumentException ex = Assert.Throws<ArgumentException>(() => new AppInfo { Id = id!, DisplayName = "X" });
|
|
Assert.Equal("value", ex.ParamName);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>DataDirectoryVariable: is upper snake case of id.</summary>
|
|
[Fact]
|
|
public void DataDirectoryVariable_IsUpperSnakeCaseOfId()
|
|
{
|
|
Assert.Equal("MY_APP_DATA_DIR", new AppInfo { Id = "my-app", DisplayName = "X" }.DataDirectoryVariable);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Defaults: are empty and brand is set.</summary>
|
|
[Fact]
|
|
public void Defaults_AreEmptyAndBrandIsSet()
|
|
{
|
|
var app = new AppInfo { Id = "x", DisplayName = "X" };
|
|
Assert.Equal("", app.Tagline);
|
|
Assert.Equal("", app.Description);
|
|
Assert.Null(app.RepositoryUrl);
|
|
Assert.Equal("", app.Author);
|
|
Assert.Equal("", app.AuthorEmail);
|
|
Assert.Null(app.Website);
|
|
Assert.Equal("MIT License", app.License);
|
|
Assert.Equal("", app.Copyright);
|
|
Assert.Empty(app.Attributions);
|
|
Assert.Equal("#3B4CCA", app.Brand.GradientStart);
|
|
Assert.Equal("#2A2F8F", app.Brand.GradientEnd);
|
|
Assert.Equal("Assets/Brand/app-icon.svg", app.Brand.IconSvgPath);
|
|
Assert.Equal("Assets/Brand/app.ico", app.Brand.IconIcoPath);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>AllProperties: round trip.</summary>
|
|
[Fact]
|
|
public void AllProperties_RoundTrip()
|
|
{
|
|
var app = new AppInfo
|
|
{
|
|
Id = "x",
|
|
DisplayName = "X",
|
|
Tagline = "t",
|
|
Description = "d",
|
|
RepositoryUrl = new Uri("https://github.com/a/b"),
|
|
Author = "a",
|
|
AuthorEmail = "a@b.c",
|
|
Website = new Uri("https://b.c"),
|
|
License = "Apache-2.0",
|
|
Copyright = "(c)",
|
|
Brand = new AppBrand { GradientStart = "#000000", GradientEnd = "#FFFFFF", IconSvgPath = "a.svg", IconIcoPath = "a.ico" },
|
|
Attributions = [new Attribution("Velopack", "MIT License")],
|
|
};
|
|
Assert.Equal("Velopack", app.Attributions[0].Name);
|
|
Assert.Equal("MIT License", app.Attributions[0].License);
|
|
Assert.Equal("a.ico", app.Brand.IconIcoPath);
|
|
Assert.Equal(app, app with { });
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Tests for parsing brand colors.</summary>
|
|
public class ArgbColorTests
|
|
{
|
|
/// <summary>Parse: six digits is opaque.</summary>
|
|
[Fact]
|
|
public void Parse_SixDigits_IsOpaque()
|
|
{
|
|
Assert.Equal(new ArgbColor(0xFF, 0x3B, 0x4C, 0xCA), ArgbColor.Parse("#3B4CCA"));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Parse: eight digits keeps alpha.</summary>
|
|
[Fact]
|
|
public void Parse_EightDigits_KeepsAlpha()
|
|
{
|
|
Assert.Equal(new ArgbColor(0x33, 0xFF, 0xFF, 0xFF), ArgbColor.Parse("#33ffffff"));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Components: are exposed.</summary>
|
|
[Fact]
|
|
public void Components_AreExposed()
|
|
{
|
|
var color = ArgbColor.Parse("#01020304");
|
|
Assert.Equal((1, 2, 3, 4), (color.A, color.R, color.G, color.B));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>TryParse: rejects malformed.</summary>
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("3B4CCA1")]
|
|
[InlineData("#3B4CC")]
|
|
[InlineData("#GGGGGG")]
|
|
public void TryParse_RejectsMalformed(string? text)
|
|
{
|
|
Assert.False(ArgbColor.TryParse(text, out ArgbColor color));
|
|
Assert.Equal(default, color);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Parse: malformed throws.</summary>
|
|
[Fact]
|
|
public void Parse_Malformed_Throws()
|
|
{
|
|
Assert.Throws<FormatException>(() => ArgbColor.Parse("red"));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Tests for the per-user data paths and the data-directory override.</summary>
|
|
public class AppPathsTests
|
|
{
|
|
private static readonly AppInfo s_app = new() { Id = "my-app", DisplayName = "My App" };
|
|
|
|
|
|
|
|
/// <summary>DataRoot: defaults to local app data.</summary>
|
|
[Fact]
|
|
public void DataRoot_DefaultsToLocalAppData()
|
|
{
|
|
var paths = new AppPaths(s_app, _ => null);
|
|
string expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "my-app");
|
|
Assert.Equal(expected, paths.DataRoot);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>DataRoot: empty override uses default.</summary>
|
|
[Fact]
|
|
public void DataRoot_EmptyOverride_UsesDefault()
|
|
{
|
|
var paths = new AppPaths(s_app, _ => "");
|
|
Assert.EndsWith("my-app", paths.DataRoot, StringComparison.Ordinal);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>DataRoot: honors override variable.</summary>
|
|
[Fact]
|
|
public void DataRoot_HonorsOverrideVariable()
|
|
{
|
|
string? asked = null;
|
|
var paths = new AppPaths(s_app, name =>
|
|
{
|
|
asked = name;
|
|
return @"D:\portable";
|
|
});
|
|
Assert.Equal(@"D:\portable", paths.DataRoot);
|
|
Assert.Equal("MY_APP_DATA_DIR", asked);
|
|
Assert.Equal(@"D:\portable\logs", paths.LogsDirectory);
|
|
Assert.Equal(@"D:\portable\settings.json", paths.SettingsFile);
|
|
Assert.Equal(@"D:\portable\accounts.json", paths.GetFile("accounts.json"));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>PublicConstructor: reads the real environment.</summary>
|
|
[Fact]
|
|
public void PublicConstructor_ReadsTheRealEnvironment()
|
|
{
|
|
var unique = new AppInfo { Id = "appkit-test-" + Guid.NewGuid().ToString("N"), DisplayName = "X" };
|
|
Assert.EndsWith(unique.Id, new AppPaths(unique).DataRoot, StringComparison.Ordinal);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Constructor: null app throws.</summary>
|
|
[Fact]
|
|
public void Constructor_NullApp_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new AppPaths(null!));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Tests for the build identity text and prerelease detection.</summary>
|
|
public class BuildVersionTests
|
|
{
|
|
/// <summary>Format: shortens commit metadata.</summary>
|
|
[Theory]
|
|
[InlineData(null, "unknown")]
|
|
[InlineData(" ", "unknown")]
|
|
[InlineData("1.2.3", "1.2.3")]
|
|
[InlineData("1.2.3-beta.1+abc", "1.2.3-beta.1 (abc)")]
|
|
[InlineData("1.2.3+0123456789abcdef", "1.2.3 (012345678)")]
|
|
[InlineData("1.2.3+abc.more", "1.2.3 (abc)")]
|
|
[InlineData("1.2.3+", "1.2.3")]
|
|
public void Format_ShortensCommitMetadata(string? input, string expected)
|
|
{
|
|
Assert.Equal(expected, BuildVersion.Format(input));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>IsPrerelease: looks only at the version part.</summary>
|
|
[Theory]
|
|
[InlineData(null, false)]
|
|
[InlineData("1.2.3", false)]
|
|
[InlineData("1.2.3+sha-with-dash", false)]
|
|
[InlineData("1.2.3-dev", true)]
|
|
[InlineData("1.2.3-beta.1+abc", true)]
|
|
public void IsPrerelease_LooksOnlyAtTheVersionPart(string? input, bool expected)
|
|
{
|
|
Assert.Equal(expected, BuildVersion.IsPrerelease(input));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Describe: reads informational version.</summary>
|
|
[Fact]
|
|
public void Describe_ReadsInformationalVersion()
|
|
{
|
|
Assembly assembly = Emit(informationalVersion: "4.5.6-rc.1+deadbeef", version: null);
|
|
Assert.Equal("4.5.6-rc.1 (deadbeef)", BuildVersion.Describe(assembly));
|
|
Assert.True(BuildVersion.IsPrerelease(assembly));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Describe: falls back to assembly version.</summary>
|
|
[Fact]
|
|
public void Describe_FallsBackToAssemblyVersion()
|
|
{
|
|
Assembly assembly = Emit(informationalVersion: null, version: new Version(1, 2, 3, 4));
|
|
Assert.Equal("1.2.3.4", BuildVersion.Describe(assembly));
|
|
Assert.False(BuildVersion.IsPrerelease(assembly));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Describe: no version attributes is zero version.</summary>
|
|
[Fact]
|
|
public void Describe_NoVersionAttributes_IsZeroVersion()
|
|
{
|
|
Assembly assembly = Emit(informationalVersion: null, version: null);
|
|
Assert.Equal("0.0.0.0", BuildVersion.Describe(assembly));
|
|
Assert.False(BuildVersion.IsPrerelease(assembly));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>NullAssembly: throws.</summary>
|
|
[Fact]
|
|
public void NullAssembly_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => BuildVersion.Describe(null!));
|
|
Assert.Throws<ArgumentNullException>(() => BuildVersion.IsPrerelease((Assembly)null!));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Emits an in-memory assembly with the given informational and assembly versions, so every fallback branch can be tested.</summary>
|
|
private static AssemblyBuilder Emit(string? informationalVersion, Version? version)
|
|
{
|
|
var name = new AssemblyName("Emitted" + Guid.NewGuid().ToString("N")) { Version = version };
|
|
var builder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run);
|
|
if(informationalVersion is not null)
|
|
{
|
|
ConstructorInfo ctor = typeof(AssemblyInformationalVersionAttribute).GetConstructor([typeof(string)])!;
|
|
builder.SetCustomAttribute(new CustomAttributeBuilder(ctor, [informationalVersion]));
|
|
}
|
|
return builder;
|
|
}
|
|
}
|