Files
james.maesandClaude Opus 5.5 839fb72bec chore: adopt the KofTwentyTwo standards (#2)
## 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>
2026-10-09 14:43:17 -05:00

275 lines
9.0 KiB
C#

/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Tests for the daily file log: entry format, rollover, retention, and failure handling.</summary>
public sealed class FileActivityLogTests : IDisposable
{
private readonly TempDirectory _temp = new();
private readonly ManualTimeProvider _clock = new(new DateTimeOffset(2026, 3, 15, 9, 30, 0, TimeSpan.Zero));
/// <summary>Deletes the test&apos;s temporary folder.</summary>
public void Dispose() => _temp.Dispose();
/// <summary>Writes: timestamped levels to todays file.</summary>
[Fact]
public void Writes_TimestampedLevels_ToTodaysFile()
{
var log = new FileActivityLog(_temp.Path, "app", time: _clock);
log.Info("hello");
log.Warning("careful");
log.Error("boom", new InvalidOperationException("inner detail"));
log.Error("plain");
Assert.Equal(_temp.File("app-2026-03-15.log"), log.CurrentLogFilePath);
string text = File.ReadAllText(log.CurrentLogFilePath);
Assert.Contains("2026-03-15 09:30:00.000 [INFO] hello", text, StringComparison.Ordinal);
Assert.Contains("[WARN] careful", text, StringComparison.Ordinal);
Assert.Contains("[ERROR] boom", text, StringComparison.Ordinal);
Assert.Contains("inner detail", text, StringComparison.Ordinal);
Assert.Contains("[ERROR] plain", text, StringComparison.Ordinal);
}
/// <summary>RollsOver: at midnight.</summary>
[Fact]
public void RollsOver_AtMidnight()
{
var log = new FileActivityLog(_temp.Path, "app", time: _clock);
log.Info("day one");
_clock.Now = _clock.Now.AddDays(1);
log.Info("day two");
Assert.True(File.Exists(_temp.File("app-2026-03-15.log")));
Assert.Contains("day two", File.ReadAllText(_temp.File("app-2026-03-16.log")), StringComparison.Ordinal);
}
/// <summary>Prunes: files outside retention once per day.</summary>
[Fact]
public void Prunes_FilesOutsideRetention_OncePerDay()
{
File.WriteAllText(_temp.File("app-2026-03-13.log"), "kept: inside 3 days");
File.WriteAllText(_temp.File("app-2026-03-12.log"), "pruned");
File.WriteAllText(_temp.File("app-notadate.log"), "ignored: not a dated name");
File.WriteAllText(_temp.File("other-2020-01-01.log"), "ignored: another prefix");
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 3, time: _clock);
log.Info("first");
Assert.False(File.Exists(_temp.File("app-2026-03-12.log")));
Assert.True(File.Exists(_temp.File("app-2026-03-13.log")));
Assert.True(File.Exists(_temp.File("app-notadate.log")));
Assert.True(File.Exists(_temp.File("other-2020-01-01.log")));
// Already pruned today: a newly stale file survives until tomorrow's first write.
File.WriteAllText(_temp.File("app-2026-01-01.log"), "stale");
log.Info("second");
Assert.True(File.Exists(_temp.File("app-2026-01-01.log")));
_clock.Now = _clock.Now.AddDays(1);
log.Info("next day");
Assert.False(File.Exists(_temp.File("app-2026-01-01.log")));
Assert.False(File.Exists(_temp.File("app-2026-03-13.log")));
}
/// <summary>Prune: skips files it cannot delete.</summary>
[Fact]
public void Prune_SkipsFilesItCannotDelete()
{
string locked = _temp.File("app-2020-01-01.log");
File.WriteAllText(locked, "held open");
using(new FileStream(locked, FileMode.Open, FileAccess.Read, FileShare.None))
{
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 1, time: _clock);
log.Info("still logs");
Assert.True(File.Exists(log.CurrentLogFilePath));
}
Assert.True(File.Exists(locked));
}
/// <summary>ZeroRetention: keeps everything.</summary>
[Fact]
public void ZeroRetention_KeepsEverything()
{
File.WriteAllText(_temp.File("app-2000-01-01.log"), "ancient");
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 0, time: _clock);
log.Info("x");
Assert.Equal(0, log.RetentionDays);
Assert.True(File.Exists(_temp.File("app-2000-01-01.log")));
}
/// <summary>WriteFailures: are swallowed.</summary>
[Fact]
public void WriteFailures_AreSwallowed()
{
string notADirectory = _temp.File("file");
File.WriteAllText(notADirectory, "");
var log = new FileActivityLog(notADirectory, "app", time: _clock);
log.Info("goes nowhere");
log.Error("also nowhere", new InvalidOperationException());
Assert.Equal(notADirectory, log.LogDirectory);
}
/// <summary>ForApp: uses the apps logs folder and id.</summary>
[Fact]
public void ForApp_UsesTheAppsLogsFolderAndId()
{
var app = new AppInfo { Id = "my-app", DisplayName = "X" };
var paths = new AppPaths(app, _ => _temp.Path);
var log = FileActivityLog.ForApp(app, paths);
Assert.Equal(paths.LogsDirectory, log.LogDirectory);
Assert.Equal(FileActivityLog.DefaultRetentionDays, log.RetentionDays);
Assert.StartsWith("my-app-", Path.GetFileName(log.CurrentLogFilePath), StringComparison.Ordinal);
Assert.Throws<ArgumentNullException>(() => FileActivityLog.ForApp(null!, paths));
Assert.Throws<ArgumentNullException>(() => FileActivityLog.ForApp(app, null!));
}
/// <summary>Constructor: validates arguments.</summary>
[Fact]
public void Constructor_ValidatesArguments()
{
Assert.Throws<ArgumentException>(() => new FileActivityLog(" ", "app"));
Assert.Throws<ArgumentException>(() => new FileActivityLog(_temp.Path, ""));
Assert.NotNull(new FileActivityLog(_temp.Path, "app").CurrentLogFilePath);
}
/// <summary>NullLog: discards everything.</summary>
[Fact]
public void NullLog_DiscardsEverything()
{
NullActivityLog log = NullActivityLog.Instance;
log.Info("a");
log.Warning("b");
log.Error("c", new InvalidOperationException());
Assert.Equal("", log.LogDirectory);
Assert.Equal("", log.CurrentLogFilePath);
}
}
/// <summary>Tests for reading and filtering log tails.</summary>
public sealed class LogTailTests : IDisposable
{
private readonly TempDirectory _temp = new();
/// <summary>Deletes the test&apos;s temporary folder.</summary>
public void Dispose() => _temp.Dispose();
/// <summary>Read: missing file is empty.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(@"Z:\does\not\exist.log")]
public void Read_MissingFile_IsEmpty(string? path)
{
LogSnapshot snapshot = LogTail.Read(path);
Assert.Empty(snapshot.Lines);
Assert.Equal(0, snapshot.Length);
}
/// <summary>Read: keeps only the tail.</summary>
[Fact]
public void Read_KeepsOnlyTheTail()
{
string path = _temp.File("a.log");
File.WriteAllLines(path, ["1", "2", "3", "4"]);
LogSnapshot snapshot = LogTail.Read(path, maxLines: 2);
Assert.Equal(["3", "4"], snapshot.Lines);
Assert.Equal(new FileInfo(path).Length, snapshot.Length);
}
/// <summary>Read: locked file returns an explanation.</summary>
[Fact]
public void Read_LockedFile_ReturnsAnExplanation()
{
string path = _temp.File("locked.log");
File.WriteAllText(path, "x");
using var hold = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
LogSnapshot snapshot = LogTail.Read(path);
Assert.Equal("Could not read the log file.", snapshot.Lines[0]);
Assert.Equal(0, snapshot.Length);
}
/// <summary>Read: rejects non positive line counts.</summary>
[Fact]
public void Read_RejectsNonPositiveLineCounts()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LogTail.Read("x", 0));
}
/// <summary>ErrorsOnly: keeps error entries with continuation lines.</summary>
[Fact]
public void ErrorsOnly_KeepsErrorEntriesWithContinuationLines()
{
string[] lines =
[
"stray line before any entry",
"2026-01-01 10:00:00.000 [INFO] fine",
"2026-01-01 10:00:01.000 [ERROR] broke",
"System.Exception: detail",
" at Somewhere()",
"2026-01-01 10:00:02.000 [WARN] meh",
];
Assert.Equal(
["2026-01-01 10:00:01.000 [ERROR] broke", "System.Exception: detail", " at Somewhere()"],
LogTail.ErrorsOnly(lines));
}
/// <summary>ToDisplayText: has friendly empty states.</summary>
[Fact]
public void ToDisplayText_HasFriendlyEmptyStates()
{
Assert.Equal("No log entries yet.", LogTail.ToDisplayText([], errorsOnly: false));
Assert.Equal("No errors logged.", LogTail.ToDisplayText([], errorsOnly: true));
Assert.Equal("a" + Environment.NewLine + "b", LogTail.ToDisplayText(["a", "b"], errorsOnly: false));
}
/// <summary>NullArguments: throw.</summary>
[Fact]
public void NullArguments_Throw()
{
Assert.Throws<ArgumentNullException>(() => LogTail.ErrorsOnly(null!));
Assert.Throws<ArgumentNullException>(() => LogTail.ToDisplayText(null!, false));
}
}