From f254c8ecbb2a42c7591527212d436f4c8cb91ab4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:38:20 -0400 Subject: [PATCH] test(aot): smoke-check every public member under Native AOT, and guard it The AotSmoke app called each public method once, mostly on the happy path. It now runs 46 checks, each naming the PublicAPI member it covers: - both value-type and reference-type (plus a user-defined struct) instantiations of every generic member, since AOT compiles value-type generics separately; - the exception paths: argument guards, failures, a pre-canceled token on both RunAsync overloads, Value on a failed Result; - the whitespace-message fallback, which reads ex.GetType().Name, the one place the library touches type metadata; - the protected Result(bool, string?) constructor, through a subclass; - the combinators' empty, null, false and multi-failure paths. AotSmokeCoverageTests (unit suite, net8.0+) reads every PublicAPI.*.txt, derives the member keys, and fails naming any member with no Check in the smoke app, so new public API cannot ship without an AOT smoke check. String parsing rather than [GeneratedRegex]: generated code would count against the 100% test-assembly coverage gate. Co-Authored-By: Claude Opus 5.5 --- tests/Wolfgang.TryPattern.AotSmoke/Program.cs | 224 +++++++++++++----- .../SmokeTypes.cs | 13 + .../AotSmokeCoverageTests.cs | 95 ++++++++ 3 files changed, 267 insertions(+), 65 deletions(-) create mode 100644 tests/Wolfgang.TryPattern.AotSmoke/SmokeTypes.cs create mode 100644 tests/Wolfgang.TryPattern.Tests.Unit/AotSmokeCoverageTests.cs diff --git a/tests/Wolfgang.TryPattern.AotSmoke/Program.cs b/tests/Wolfgang.TryPattern.AotSmoke/Program.cs index ca41294c..f574f2ea 100644 --- a/tests/Wolfgang.TryPattern.AotSmoke/Program.cs +++ b/tests/Wolfgang.TryPattern.AotSmoke/Program.cs @@ -1,80 +1,174 @@ -// Native AOT / trimming smoke test for the AOT-safe public surface of -// Wolfgang.TryPattern. Published with PublishAot + PublishTrimmed (see -// the .csproj) and run by CI: a trim/AOT-unsafe regression makes the -// analyzers warn (TreatWarningsAsErrors → build fails), and a runtime -// break (MissingMethodException / NotSupportedException / silent no-op) -// makes this program exit non-zero. +// Native AOT / trimming smoke test for the whole public surface of +// Wolfgang.TryPattern. Published with PublishAot + PublishTrimmed (see the +// .csproj) and run by CI (.github/workflows/aot-smoke.yaml): a trim/AOT-unsafe +// regression makes the analyzers warn (TreatWarningsAsErrors -> build fails), +// and a runtime break (MissingMethodException / NotSupportedException / a +// silently wrong result) makes this program exit non-zero. // -// Try-Pattern has no reflection, no dynamic-code paths, no serialization — -// the entire public surface is expected to be AOT-safe. Every public -// method is exercised at least once below, both success and failure -// paths, so a future refactor that inadvertently pulls in a -// reflection-dependent code path is caught here. +// Every check names the public API member it covers, as the member appears in +// src/Wolfgang.TryPattern/**/PublicAPI.*.txt with the namespace, `static` and +// `.get` removed (e.g. "Result.Flatten", "Result.Value", "Try.RunAsync"). +// AotSmokeCoverageTests in the unit suite fails when a public member has no +// Check here, so new API cannot ship without an AOT smoke check. +// +// Beyond one call per member, the paths most likely to break under AOT are +// covered on purpose: +// - value-type AND reference-type instantiations of every generic member +// (AOT compiles value-type generics separately; reference types share code), +// - exception paths (guards, failures, cancellation), which pull in +// exception-handling and stack-trace machinery, +// - the whitespace-message fallback, which reads ex.GetType().Name — the one +// place the library touches type metadata. using System.Globalization; using Wolfgang.TryPattern; +using Wolfgang.TryPattern.AotSmoke; -int expected = 0; -int actual = 0; - -// Try.Run(Action) — success + failure. -Result r1 = Try.Run(() => { }); -expected++; if (r1.Succeeded) actual++; - -Result r2 = Try.Run(() => throw new InvalidOperationException("boom")); -expected++; if (r2.Failed && string.Equals(r2.ErrorMessage, "boom", StringComparison.Ordinal)) actual++; - -// Try.Run(Func) — success + failure. T=int under nullable-enable and -// unconstrained T maps `Result` back to Result (int? in a -// value-type context with no `where T : struct`). -Result r3 = Try.Run(() => 42); -expected++; if (r3.Succeeded && r3.Value == 42) actual++; - -Result r4 = Try.Run(() => throw new InvalidOperationException("boom-t")); -expected++; if (r4.Failed && string.Equals(r4.ErrorMessage, "boom-t", StringComparison.Ordinal)) actual++; - -// Try.RunAsync(Action, CancellationToken) — success + failure. -Result r5 = await Try.RunAsync(() => { }); -expected++; if (r5.Succeeded) actual++; - -Result r6 = await Try.RunAsync(() => throw new InvalidOperationException("async-boom")); -expected++; if (r6.Failed && string.Equals(r6.ErrorMessage, "async-boom", StringComparison.Ordinal)) actual++; - -// Try.RunAsync(Func>, CancellationToken) — success + failure. -Result r7 = await Try.RunAsync(() => Task.FromResult("ok")); -expected++; if (r7.Succeeded && string.Equals(r7.Value, "ok", StringComparison.Ordinal)) actual++; - -Result r8 = await Try.RunAsync(() => throw new InvalidOperationException("async-boom-t")); -expected++; if (r8.Failed && string.Equals(r8.ErrorMessage, "async-boom-t", StringComparison.Ordinal)) actual++; - -// Result static factories + combinators. -Result r9 = Result.Success(); -expected++; if (r9.Succeeded) actual++; +var failures = new List(); +var checks = 0; -Result r10 = Result.Failure("nope"); -expected++; if (r10.Failed && string.Equals(r10.ErrorMessage, "nope", StringComparison.Ordinal)) actual++; +static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.Ordinal); -Result r11 = Result.Flatten(Result.Success(), Result.Success()); -expected++; if (r11.Succeeded) actual++; - -Result r12 = Result.Flatten(Result.Success(), Result.Failure("nope-flat")); -expected++; if (r12.Failed) actual++; - -expected++; if (Result.AnyFailed(Result.Success(), Result.Failure("f"))) actual++; -expected++; if (Result.AllSucceeded(Result.Success(), Result.Success())) actual++; +void Check(string api, bool condition, string what) +{ + checks++; + if (!condition) + { + failures.Add($"{api}: {what}"); + } +} -// Result factories. -Result r13 = Result.Success(7); -expected++; if (r13.Succeeded && r13.Value == 7) actual++; +void Throws(string api, Action action, string what) + where TException : Exception +{ + Exception? caught = null; + try + { + action(); + } + catch (Exception ex) + { + caught = ex; + } + + Check(api, caught is TException, $"{what} (threw {caught?.GetType().Name ?? "nothing"})"); +} -Result r14 = Result.Failure("nope-t"); -expected++; if (r14.Failed && string.Equals(r14.ErrorMessage, "nope-t", StringComparison.Ordinal)) actual++; +async Task ThrowsAsync(string api, Func action, string what) + where TException : Exception +{ + Exception? caught = null; + try + { + await action(); + } + catch (Exception ex) + { + caught = ex; + } + + Check(api, caught is TException, $"{what} (threw {caught?.GetType().Name ?? "nothing"})"); +} -if (actual != expected) +using var canceled = new CancellationTokenSource(); +await canceled.CancelAsync(); + +// ---- Try.Run(Action) ------------------------------------------------------- +Result run = Try.Run(() => { }); +Check("Try.Run", run.Succeeded, "success"); +run = Try.Run(() => throw new InvalidOperationException("boom")); +Check("Try.Run", run.Failed && Same(run.ErrorMessage, "boom"), "failure carries the exception message"); +run = Try.Run(() => throw new InvalidOperationException(" ")); +Check("Try.Run", run.Failed && Same(run.ErrorMessage, nameof(InvalidOperationException)), "whitespace message falls back to the exception type name"); +Throws("Try.Run", () => Try.Run(null!), "null action"); + +// ---- Try.Run(Func) — value type and reference type ------------------- +Result runInt = Try.Run(() => 42); +Check("Try.Run", runInt.Succeeded && runInt.Value == 42, "value-type success"); +runInt = Try.Run(() => throw new InvalidOperationException("boom-t")); +Check("Try.Run", runInt.Failed && Same(runInt.ErrorMessage, "boom-t"), "value-type failure"); +Result runString = Try.Run(() => "text"); +Check("Try.Run", runString.Succeeded && Same(runString.Value, "text"), "reference-type success"); +Result runPoint = Try.Run(() => new Point(1, 2)); +Check("Try.Run", runPoint.Succeeded && runPoint.Value == new Point(1, 2), "user-defined struct success"); +Throws("Try.Run", () => Try.Run(null!), "null function"); + +// ---- Try.RunAsync(Action, CancellationToken) -------------------------------- +Result runAsync = await Try.RunAsync(() => { }); +Check("Try.RunAsync", runAsync.Succeeded, "success"); +runAsync = await Try.RunAsync(() => throw new InvalidOperationException("async-boom"), CancellationToken.None); +Check("Try.RunAsync", runAsync.Failed && Same(runAsync.ErrorMessage, "async-boom"), "failure"); +await ThrowsAsync("Try.RunAsync", () => Try.RunAsync(() => { }, canceled.Token), "pre-canceled token propagates cancellation"); +await ThrowsAsync("Try.RunAsync", () => Try.RunAsync(null!), "null action"); + +// ---- Try.RunAsync(Func>, CancellationToken) — both kinds of T ----- +Result runAsyncString = await Try.RunAsync(() => Task.FromResult("ok")); +Check("Try.RunAsync", runAsyncString.Succeeded && Same(runAsyncString.Value, "ok"), "reference-type success"); +runAsyncString = await Try.RunAsync(() => throw new InvalidOperationException("async-boom-t")); +Check("Try.RunAsync", runAsyncString.Failed && Same(runAsyncString.ErrorMessage, "async-boom-t"), "reference-type failure"); +Result runAsyncInt = await Try.RunAsync(async () => { - System.Console.Error.WriteLine(string.Create(CultureInfo.InvariantCulture, $"FAIL: expected {expected} AOT-safe assertions, got {actual}.")); + await Task.Yield(); + return 7; +}); +Check("Try.RunAsync", runAsyncInt.Succeeded && runAsyncInt.Value == 7, "value-type success across an await"); +await ThrowsAsync("Try.RunAsync", () => Try.RunAsync(() => Task.FromResult(1), canceled.Token), "pre-canceled token propagates cancellation"); +await ThrowsAsync("Try.RunAsync", () => Try.RunAsync(null!), "null function"); + +// ---- Result: constructor, factories, properties ----------------------------- +// The constructor is protected: exercise it the way a consumer can, through a subclass. +Check("Result.Result", new CustomResult(true, null).Succeeded, "succeeded with no message"); +Check("Result.Result", Same(new CustomResult(false, "bad").ErrorMessage, "bad"), "failed with a message"); +Throws("Result.Result", () => _ = new CustomResult(true, "unexpected"), "a success must not carry a message"); +Throws("Result.Result", () => _ = new CustomResult(false, " "), "a failure needs a non-whitespace message"); + +Result success = Result.Success(); +Check("Result.Success", success.Succeeded, "succeeds"); +Check("Result.Succeeded", success.Succeeded && !Result.Failure("x").Succeeded, "true only on success"); +Check("Result.Failed", Result.Failure("x").Failed && !success.Failed, "true only on failure"); +Check("Result.ErrorMessage", success.ErrorMessage is null && Same(Result.Failure("why").ErrorMessage, "why"), "null on success, the message on failure"); +Check("Result.Failure", Result.Failure("nope").Failed, "fails"); +Throws("Result.Failure", () => Result.Failure(" "), "whitespace message"); + +// ---- Result combinators ------------------------------------------------------ +Check("Result.Flatten", Result.Flatten(Result.Success(), Result.Success()).Succeeded, "all successes"); +Result flat = Result.Flatten(Result.Success(), Result.Failure("first"), Result.Failure("second")); +Check("Result.Flatten", flat.Failed && flat.ErrorMessage!.Contains("first", StringComparison.Ordinal) && flat.ErrorMessage.Contains("second", StringComparison.Ordinal), "joins every failure message"); +Check("Result.Flatten", Result.Flatten().Succeeded, "empty input succeeds"); +Throws("Result.Flatten", () => Result.Flatten(null), "null array"); + +Check("Result.AnyFailed", Result.AnyFailed(Result.Success(), Result.Failure("f")), "true with a failure"); +Check("Result.AnyFailed", !Result.AnyFailed(Result.Success()) && !Result.AnyFailed(), "false with none, and when empty"); +Throws("Result.AnyFailed", () => Result.AnyFailed(null), "null array"); + +Check("Result.AllSucceeded", Result.AllSucceeded(Result.Success(), Result.Success()) && Result.AllSucceeded(), "true when all succeed, and when empty"); +Check("Result.AllSucceeded", !Result.AllSucceeded(Result.Success(), Result.Failure("f")), "false with a failure"); +Throws("Result.AllSucceeded", () => Result.AllSucceeded(Result.Success(), null!), "null element"); + +// ---- Result — value type, reference type, user-defined struct ------------- +Result okInt = Result.Success(7); +Check("Result.Success", okInt.Succeeded && okInt.Value == 7, "value type"); +Result okString = Result.Success("s"); +Check("Result.Success", okString.Succeeded && Same(okString.Value, "s"), "reference type"); +Result okPoint = Result.Success(new Point(3, 4)); +Check("Result.Success", okPoint.Value == new Point(3, 4), "user-defined struct"); +Check("Result.Value", okInt.Value == 7 && Same(okString.Value, "s"), "returns the value on success"); +Throws("Result.Value", () => _ = Result.Failure("no").Value, "throws on a failed result"); +Result badInt = Result.Failure("nope-t"); +Check("Result.Failure", badInt.Failed && Same(badInt.ErrorMessage, "nope-t"), "value type"); +Check("Result.Failure", Result.Failure("nope-s").Failed, "reference type"); +Throws("Result.Failure", () => Result.Failure(""), "empty message"); + +if (failures.Count > 0) +{ + Console.Error.WriteLine(string.Create(CultureInfo.InvariantCulture, $"FAIL: {failures.Count} of {checks} AOT smoke checks failed:")); + foreach (var failure in failures) + { + Console.Error.WriteLine($" {failure}"); + } + return 1; } -System.Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"OK: AOT-safe surface passed {actual} assertions under Native AOT.")); +Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"OK: {checks} AOT smoke checks passed under Native AOT.")); return 0; diff --git a/tests/Wolfgang.TryPattern.AotSmoke/SmokeTypes.cs b/tests/Wolfgang.TryPattern.AotSmoke/SmokeTypes.cs new file mode 100644 index 00000000..27796b8a --- /dev/null +++ b/tests/Wolfgang.TryPattern.AotSmoke/SmokeTypes.cs @@ -0,0 +1,13 @@ +namespace Wolfgang.TryPattern.AotSmoke; + +/// A user-defined value type, so the generic members are exercised with a non-BCL struct. +internal readonly record struct Point(int X, int Y); + +/// Reaches the protected constructor the way a consumer would. +internal sealed class CustomResult : Result +{ + public CustomResult(bool succeeded, string? errorMessage) + : base(succeeded, errorMessage) + { + } +} diff --git a/tests/Wolfgang.TryPattern.Tests.Unit/AotSmokeCoverageTests.cs b/tests/Wolfgang.TryPattern.Tests.Unit/AotSmokeCoverageTests.cs new file mode 100644 index 00000000..b2972f0f --- /dev/null +++ b/tests/Wolfgang.TryPattern.Tests.Unit/AotSmokeCoverageTests.cs @@ -0,0 +1,95 @@ +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Wolfgang.TryPattern.Tests.Unit; + +/// +/// Keeps the Native AOT smoke app (tests/Wolfgang.TryPattern.AotSmoke) in step +/// with the public API: every member listed in the PublicAPI baseline files must +/// have at least one Check("<member>", ...) in the smoke app, so a new +/// public member cannot ship without being exercised under Native AOT. +/// +/// +/// Plain string parsing on purpose: [GeneratedRegex] emits source-generated code +/// into this assembly, which the 100% test-assembly coverage gate would count. +/// +public class AotSmokeCoverageTests +{ + private const string Namespace = "Wolfgang.TryPattern."; + + + + [Fact] + public async Task AotSmoke_when_compared_to_the_PublicAPI_files_checks_every_public_member() + { + var repoRoot = LocateRepoRoot(); + var apiFiles = Directory.GetFiles + ( + Path.Combine(repoRoot, "src", "Wolfgang.TryPattern"), + "PublicAPI.*.txt", + SearchOption.AllDirectories + ); + var smokeSource = await File.ReadAllTextAsync(Path.Combine(repoRoot, "tests", "Wolfgang.TryPattern.AotSmoke", "Program.cs")); + + var publicMembers = new SortedSet(StringComparer.Ordinal); + foreach (var file in apiFiles) + { + publicMembers.UnionWith((await File.ReadAllLinesAsync(file)).SelectMany(MemberKey)); + } + + var checkedMembers = CheckedMembers(smokeSource).ToHashSet(StringComparer.Ordinal); + + // Guards the parser itself: if it stopped recognising the files, an empty + // set would make the comparison below pass vacuously. + Assert.Contains("Try.RunAsync", publicMembers); + Assert.Equal + ( + Array.Empty(), + publicMembers.Where(m => !checkedMembers.Contains(m)).ToArray() + ); + } + + + + // "static Wolfgang.TryPattern.Result.Failure(string! errorMessage) -> ..." -> "Result.Failure" + // "Wolfgang.TryPattern.Result.Value.get -> T?" -> "Result.Value" + // Type declarations ("Wolfgang.TryPattern.Result") have neither "(" nor + // " ->", and the "#nullable enable" header lacks the namespace; both yield nothing. + private static IEnumerable MemberKey(string line) + { + var text = line.StartsWith("static ", StringComparison.Ordinal) ? line["static ".Length..] : line; + var end = new[] { text.IndexOf('(', StringComparison.Ordinal), text.IndexOf(" ->", StringComparison.Ordinal) } + .Where(i => i > 0) + .DefaultIfEmpty(-1) + .Min(); + return text.StartsWith(Namespace, StringComparison.Ordinal) && end > 0 + ? [text[Namespace.Length..end].Replace(".get", "", StringComparison.Ordinal)] + : []; + } + + + + // The first string argument of every Check("...") and Throws("...") / + // ThrowsAsync("...") call in the smoke app. + private static IEnumerable CheckedMembers(string source) => + new[] { "Check(\"", ">(\"" } + .SelectMany(marker => source.Split(marker).Skip(1)) + .Select(rest => rest[..rest.IndexOf('"', StringComparison.Ordinal)]); + + + + private static string LocateRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (!File.Exists(Path.Combine(dir, "TryPattern.sln"))) + { + dir = Path.GetDirectoryName(dir) ?? throw new DirectoryNotFoundException("TryPattern.sln not found above " + AppContext.BaseDirectory); + } + return dir; + } +} +#endif