Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 159 additions & 65 deletions tests/Wolfgang.TryPattern.AotSmoke/Program.cs
Original file line number Diff line number Diff line change
@@ -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<T>.Value", "Try.RunAsync<T>").
// 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<T>(Func<T>) — success + failure. T=int under nullable-enable and
// unconstrained T maps `Result<T?>` back to Result<int> (int? in a
// value-type context with no `where T : struct`).
Result<int> r3 = Try.Run(() => 42);
expected++; if (r3.Succeeded && r3.Value == 42) actual++;

Result<int> r4 = Try.Run<int>(() => 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<T>(Func<Task<T>>, CancellationToken) — success + failure.
Result<string?> r7 = await Try.RunAsync<string>(() => Task.FromResult<string?>("ok"));
expected++; if (r7.Succeeded && string.Equals(r7.Value, "ok", StringComparison.Ordinal)) actual++;

Result<string?> r8 = await Try.RunAsync<string>(() => 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<string>();
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<T> factories.
Result<int> r13 = Result<int>.Success(7);
expected++; if (r13.Succeeded && r13.Value == 7) actual++;
void Throws<TException>(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<int> r14 = Result<int>.Failure("nope-t");
expected++; if (r14.Failed && string.Equals(r14.ErrorMessage, "nope-t", StringComparison.Ordinal)) actual++;
async Task ThrowsAsync<TException>(string api, Func<Task> 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<ArgumentNullException>("Try.Run", () => Try.Run(null!), "null action");

// ---- Try.Run<T>(Func<T>) — value type and reference type -------------------
Result<int> runInt = Try.Run(() => 42);
Check("Try.Run<T>", runInt.Succeeded && runInt.Value == 42, "value-type success");
runInt = Try.Run<int>(() => throw new InvalidOperationException("boom-t"));
Check("Try.Run<T>", runInt.Failed && Same(runInt.ErrorMessage, "boom-t"), "value-type failure");
Result<string?> runString = Try.Run<string?>(() => "text");
Check("Try.Run<T>", runString.Succeeded && Same(runString.Value, "text"), "reference-type success");
Result<Point> runPoint = Try.Run(() => new Point(1, 2));
Check("Try.Run<T>", runPoint.Succeeded && runPoint.Value == new Point(1, 2), "user-defined struct success");
Throws<ArgumentNullException>("Try.Run<T>", () => Try.Run<int>(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<OperationCanceledException>("Try.RunAsync", () => Try.RunAsync(() => { }, canceled.Token), "pre-canceled token propagates cancellation");
await ThrowsAsync<ArgumentNullException>("Try.RunAsync", () => Try.RunAsync(null!), "null action");

// ---- Try.RunAsync<T>(Func<Task<T>>, CancellationToken) — both kinds of T -----
Result<string?> runAsyncString = await Try.RunAsync<string?>(() => Task.FromResult<string?>("ok"));
Check("Try.RunAsync<T>", runAsyncString.Succeeded && Same(runAsyncString.Value, "ok"), "reference-type success");
runAsyncString = await Try.RunAsync<string?>(() => throw new InvalidOperationException("async-boom-t"));
Check("Try.RunAsync<T>", runAsyncString.Failed && Same(runAsyncString.ErrorMessage, "async-boom-t"), "reference-type failure");
Result<int> 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<T>", runAsyncInt.Succeeded && runAsyncInt.Value == 7, "value-type success across an await");
await ThrowsAsync<OperationCanceledException>("Try.RunAsync<T>", () => Try.RunAsync(() => Task.FromResult(1), canceled.Token), "pre-canceled token propagates cancellation");
await ThrowsAsync<ArgumentNullException>("Try.RunAsync<T>", () => Try.RunAsync<int>(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<ArgumentException>("Result.Result", () => _ = new CustomResult(true, "unexpected"), "a success must not carry a message");
Throws<ArgumentException>("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<ArgumentException>("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<ArgumentNullException>("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<ArgumentNullException>("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<ArgumentException>("Result.AllSucceeded", () => Result.AllSucceeded(Result.Success(), null!), "null element");

// ---- Result<T> — value type, reference type, user-defined struct -------------
Result<int> okInt = Result<int>.Success(7);
Check("Result<T>.Success", okInt.Succeeded && okInt.Value == 7, "value type");
Result<string?> okString = Result<string>.Success("s");
Check("Result<T>.Success", okString.Succeeded && Same(okString.Value, "s"), "reference type");
Result<Point> okPoint = Result<Point>.Success(new Point(3, 4));
Check("Result<T>.Success", okPoint.Value == new Point(3, 4), "user-defined struct");
Check("Result<T>.Value", okInt.Value == 7 && Same(okString.Value, "s"), "returns the value on success");
Throws<InvalidOperationException>("Result<T>.Value", () => _ = Result<int>.Failure("no").Value, "throws on a failed result");
Result<int> badInt = Result<int>.Failure("nope-t");
Check("Result<T>.Failure", badInt.Failed && Same(badInt.ErrorMessage, "nope-t"), "value type");
Check("Result<T>.Failure", Result<string>.Failure("nope-s").Failed, "reference type");
Throws<ArgumentException>("Result<T>.Failure", () => Result<int>.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;
13 changes: 13 additions & 0 deletions tests/Wolfgang.TryPattern.AotSmoke/SmokeTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Wolfgang.TryPattern.AotSmoke;

/// <summary>A user-defined value type, so the generic members are exercised with a non-BCL struct.</summary>
internal readonly record struct Point(int X, int Y);

/// <summary>Reaches the protected <see cref="Result"/> constructor the way a consumer would.</summary>
internal sealed class CustomResult : Result
{
public CustomResult(bool succeeded, string? errorMessage)
: base(succeeded, errorMessage)
{
}
}
95 changes: 95 additions & 0 deletions tests/Wolfgang.TryPattern.Tests.Unit/AotSmokeCoverageTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <c>Check("&lt;member&gt;", ...)</c> in the smoke app, so a new
/// public member cannot ship without being exercised under Native AOT.
/// </summary>
/// <remarks>
/// Plain string parsing on purpose: [GeneratedRegex] emits source-generated code
/// into this assembly, which the 100% test-assembly coverage gate would count.
/// </remarks>
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<string>(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<T>", publicMembers);
Assert.Equal
(
Array.Empty<string>(),
publicMembers.Where(m => !checkedMembers.Contains(m)).ToArray()
);
}



// "static Wolfgang.TryPattern.Result.Failure(string! errorMessage) -> ..." -> "Result.Failure"
// "Wolfgang.TryPattern.Result<T>.Value.get -> T?" -> "Result<T>.Value"
// Type declarations ("Wolfgang.TryPattern.Result<T>") have neither "(" nor
// " ->", and the "#nullable enable" header lacks the namespace; both yield nothing.
private static IEnumerable<string> 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<T>("...") /
// ThrowsAsync<T>("...") call in the smoke app.
private static IEnumerable<string> 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
Loading