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
263 changes: 263 additions & 0 deletions FirstClassErrors.Testing/Any.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
namespace FirstClassErrors.Testing;

/// <summary>
/// Supplies arbitrary, valid values for the parts of a test that are <b>not</b> under assertion — the "any" a
/// test needs so its <c>Arrange</c> stops advertising values it never checks. Reach for it when a test says "give
/// me <i>some</i> error code / message / instant" and the exact value is irrelevant: an explicit
/// <see cref="Any" /> call reads as "this is arbitrary" where a hand-picked literal reads as "this matters".
/// </summary>
/// <remarks>
/// <para>
/// Every value is drawn from a pseudo-random source. By default that source is unseeded, so each run produces
/// fresh values — which surfaces a test that secretly depends on one. Wrap a value-sensitive test in
/// <c>Reproducibly</c> to make a failing run replayable. The source flows with the current execution context,
/// so it never leaks across tests running in parallel — the same contract the rest of this package keeps.
/// </para>
/// <para>
/// The values are <b>valid</b> (an <see cref="Any.ErrorCode" /> is never blank, an <see cref="Any.Instant" />
/// is a real UTC instant) but deliberately <b>recognizable</b> as arbitrary (codes look like
/// <c>ANY_CODE_7F3A9C</c>), so an arbitrary value that surfaces in a failure message is easy to spot.
/// </para>
/// <para>
/// <see cref="Any" /> is the error-aware surface; the generic value engine it delegates to is internal, so a
/// test never depends on it directly.
/// </para>
/// <example>
/// <code>
/// // The order id is what the test asserts on; the message is not — so it is Any.
/// Outcome&lt;Order&gt; outcome = Outcome&lt;Order&gt;.Failure(
/// DomainError.Create(ErrorCode.Create("ORDER_NOT_FOUND"), Any.DiagnosticMessage())
/// .WithPublicMessage(Any.ShortMessage()));
///
/// // Make a value-sensitive test replayable: the seed is reported on failure...
/// Any.Reproducibly(() => { /* arrange with Any, act, assert */ });
/// // ...and replayed by passing it back:
/// Any.Reproducibly(1234, () => { /* ... */ });
/// </code>
/// </example>
/// </remarks>
public static class Any {

#region Fields declarations

private const string CodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private const string TextAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789";

#endregion

/// <summary>
/// Runs <paramref name="body" /> with the arbitrary-value source pinned to a fresh seed and, if the body throws,
/// reports that seed before letting the exception propagate. This is how a test that draws on <see cref="Any" />
/// stays reproducible: the values still vary between runs (which surfaces accidental dependencies), yet a failure
/// names the exact seed to replay.
/// </summary>
/// <remarks>
/// <para>
/// On failure the seed is written to <paramref name="report" /> (by default <see cref="Console.Error" />),
/// with a message naming the <c>Any.Reproducibly(seed, ...)</c> call that reproduces the run. Pass your test
/// framework's output writer (for example xUnit's <c>ITestOutputHelper.WriteLine</c>) to route it there
/// instead. The original exception is rethrown unchanged, so the test still fails with its real message.
/// </para>
/// <para>
/// Reproducing a run needs the same sequence of <see cref="Any" /> calls, so a body whose call order depends
/// on non-deterministic external state is not fully replayable from the seed alone.
/// </para>
/// </remarks>
/// <param name="body">The test body to run under a reproducible arbitrary-value source.</param>
/// <param name="report">The sink the seed is written to on failure. Defaults to <see cref="Console.Error" /> when <c>null</c>.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="body" /> is <c>null</c>.</exception>
public static void Reproducibly(Action body, Action<string>? report = null) {
Reproducibly(ArbitrarySource.NewSeed(), body, report);
}

/// <summary>
/// Replays <paramref name="body" /> with the arbitrary-value source pinned to <paramref name="seed" />, so a run
/// first seen through the parameterless <see cref="Reproducibly(Action, Action{String})" /> overload can be
/// reproduced exactly. If the body throws, the seed is reported before the exception propagates.
/// </summary>
/// <param name="seed">The seed to replay — typically the one a previous failure reported.</param>
/// <param name="body">The test body to run under the seeded arbitrary-value source.</param>
/// <param name="report">The sink the seed is written to on failure. Defaults to <see cref="Console.Error" /> when <c>null</c>.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="body" /> is <c>null</c>.</exception>
public static void Reproducibly(int seed, Action body, Action<string>? report = null) {
if (body is null) { throw new ArgumentNullException(nameof(body)); }

using (ArbitrarySource.UseSeed(seed)) {
try {
body();
} catch {
Report(report, seed);

throw;
}
}
}

/// <summary>
/// Asynchronous counterpart of <see cref="Reproducibly(Action, Action{String})" />: awaits <paramref name="body" />
/// under a fresh seed and reports it if the body faults.
/// </summary>
/// <param name="body">The asynchronous test body to run under a reproducible arbitrary-value source.</param>
/// <param name="report">The sink the seed is written to on failure. Defaults to <see cref="Console.Error" /> when <c>null</c>.</param>
/// <returns>A task that completes when <paramref name="body" /> completes.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="body" /> is <c>null</c>.</exception>
public static Task Reproducibly(Func<Task> body, Action<string>? report = null) {
return Reproducibly(ArbitrarySource.NewSeed(), body, report);
}

/// <summary>
/// Asynchronous counterpart of <see cref="Reproducibly(int, Action, Action{String})" />: awaits
/// <paramref name="body" /> under <paramref name="seed" /> and reports it if the body faults.
/// </summary>
/// <param name="seed">The seed to replay — typically the one a previous failure reported.</param>
/// <param name="body">The asynchronous test body to run under the seeded arbitrary-value source.</param>
/// <param name="report">The sink the seed is written to on failure. Defaults to <see cref="Console.Error" /> when <c>null</c>.</param>
/// <returns>A task that completes when <paramref name="body" /> completes.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="body" /> is <c>null</c>.</exception>
public static async Task Reproducibly(int seed, Func<Task> body, Action<string>? report = null) {
if (body is null) { throw new ArgumentNullException(nameof(body)); }

using (ArbitrarySource.UseSeed(seed)) {
try {
await body().ConfigureAwait(false);
} catch {
Report(report, seed);

throw;
}
}
}

private static void Report(Action<string>? report, int seed) {
(report ?? Console.Error.WriteLine)(
$"[FirstClassErrors.Testing] These arbitrary values were seeded with {seed}. Reproduce this run with Any.Reproducibly({seed}, ...).");
}

/// <summary>
/// Returns an arbitrary <see cref="int" /> drawn from the full range of the type.
/// </summary>
/// <returns>An arbitrary integer, possibly negative.</returns>
public static int Int() {
return ArbitrarySource.Int();
}

/// <summary>
/// Returns an arbitrary <see cref="bool" />.
/// </summary>
/// <returns><c>true</c> or <c>false</c>, with even probability.</returns>
public static bool Bool() {
return ArbitrarySource.Bool();
}

/// <summary>
/// Returns an arbitrary <see cref="System.Guid" />. Unlike <see cref="System.Guid.NewGuid" />, it is drawn from
/// the seedable source, so it is reproducible inside an <c>Any.Reproducibly(...)</c> run.
/// </summary>
/// <returns>An arbitrary identifier.</returns>
public static Guid Guid() {
return ArbitrarySource.Guid();
}

/// <summary>
/// Returns an arbitrary UTC <see cref="DateTimeOffset" /> (offset <see cref="TimeSpan.Zero" />).
/// </summary>
/// <remarks>
/// To make an error's <c>OccurredAt</c> arbitrary rather than a real wall-clock reading, prefer
/// <c>Clock.UseAny()</c>, which freezes the ambient clock to an arbitrary instant for a scope.
/// </remarks>
/// <returns>An arbitrary instant, in UTC.</returns>
public static DateTimeOffset Instant() {
return ArbitrarySource.Instant();
}

/// <summary>
/// Returns an arbitrary, non-blank <see cref="string" />, prefixed with <c>any-</c> so it reads as arbitrary.
/// </summary>
/// <returns>An arbitrary string such as <c>any-4f2a9c1b</c>.</returns>
public static string String() {
return "any-" + ArbitrarySource.Token(TextAlphabet, 8);
}

/// <summary>
/// Returns an arbitrary, valid <see cref="FirstClassErrors.ErrorCode" /> — never blank, and shaped like a real
/// code (for example <c>ANY_CODE_7F3A9C</c>) so it is recognizable as arbitrary.
/// </summary>
/// <returns>An arbitrary error code.</returns>
public static ErrorCode ErrorCode() {
return FirstClassErrors.ErrorCode.Create("ANY_CODE_" + ArbitrarySource.Token(CodeAlphabet, 6));
}

/// <summary>
/// Returns an arbitrary, non-blank internal diagnostic message, suitable wherever a test needs a
/// <c>diagnosticMessage</c> it does not assert on.
/// </summary>
/// <returns>An arbitrary diagnostic message.</returns>
public static string DiagnosticMessage() {
return "Any diagnostic message " + ArbitrarySource.Token(CodeAlphabet, 6) + ".";
}

/// <summary>
/// Returns an arbitrary, non-blank public short message, suitable wherever a test needs a <c>shortMessage</c>
/// it does not assert on.
/// </summary>
/// <returns>An arbitrary short message.</returns>
public static string ShortMessage() {
return "Any short message " + ArbitrarySource.Token(CodeAlphabet, 6) + ".";
}

/// <summary>
/// Returns an arbitrary, non-blank public detailed message, suitable wherever a test needs a
/// <c>detailedMessage</c> it does not assert on.
/// </summary>
/// <returns>An arbitrary detailed message.</returns>
public static string DetailedMessage() {
return "Any detailed message " + ArbitrarySource.Token(CodeAlphabet, 6) + ".";
}

/// <summary>
/// Returns an arbitrary value of the enum <typeparamref name="TEnum" />, uniformly across <b>all</b> its
/// declared members.
/// </summary>
/// <remarks>
/// This can return a sentinel such as <c>Unknown</c>. When a test needs a <i>meaningful</i> value, prefer the
/// dedicated helpers (<see cref="Transience" />, <see cref="InteractionDirection" />), which exclude the
/// <c>Unknown</c> sentinel.
/// </remarks>
/// <typeparam name="TEnum">The enum type to draw a value from.</typeparam>
/// <returns>An arbitrary member of <typeparamref name="TEnum" />.</returns>
public static TEnum Enum<TEnum>()
where TEnum : struct, System.Enum {
return ArbitrarySource.Enum<TEnum>();
}

/// <summary>
/// Returns an arbitrary <b>meaningful</b> <see cref="FirstClassErrors.Transience" /> —
/// <see cref="FirstClassErrors.Transience.Transient" /> or
/// <see cref="FirstClassErrors.Transience.NonTransient" />, never
/// <see cref="FirstClassErrors.Transience.Unknown" />.
/// </summary>
/// <returns>An arbitrary transience classification other than <c>Unknown</c>.</returns>
public static Transience Transience() {
return ArbitrarySource.EnumExcluding(FirstClassErrors.Transience.Unknown);
}

/// <summary>
/// Returns an arbitrary <see cref="FirstClassErrors.ErrorOrigin" />, uniformly across all of its members.
/// </summary>
/// <returns>An arbitrary error origin.</returns>
public static ErrorOrigin ErrorOrigin() {
return ArbitrarySource.Enum<ErrorOrigin>();
}

/// <summary>
/// Returns an arbitrary <b>meaningful</b> <see cref="FirstClassErrors.InteractionDirection" /> —
/// <see cref="FirstClassErrors.InteractionDirection.Incoming" /> or
/// <see cref="FirstClassErrors.InteractionDirection.Outgoing" />, never
/// <see cref="FirstClassErrors.InteractionDirection.Unknown" />.
/// </summary>
/// <returns>An arbitrary interaction direction other than <c>Unknown</c>.</returns>
public static InteractionDirection InteractionDirection() {
return ArbitrarySource.EnumExcluding(FirstClassErrors.InteractionDirection.Unknown);
}

}
123 changes: 123 additions & 0 deletions FirstClassErrors.Testing/ArbitrarySource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
namespace FirstClassErrors.Testing;

/// <summary>
/// The generic, error-agnostic engine behind <see cref="Any" />: a seedable, context-local source of arbitrary
/// primitive values (numbers, booleans, identifiers, instants, tokens, enum members). It carries no knowledge of
/// FirstClassErrors — <see cref="Any" /> layers the error-specific helpers on top, and the clock and instance-id
/// seams draw their arbitrary values from here — so it stays a self-contained unit that could be extracted as a
/// standalone utility unchanged.
/// </summary>
/// <remarks>
/// The source is stored in an <see cref="AsyncLocal{T}" />, so it flows with the current execution context and
/// never leaks across tests running in parallel. Outside a <see cref="UseSeed" /> scope it is unseeded and every
/// run differs; inside one it is deterministic.
/// </remarks>
internal static class ArbitrarySource {

#region Fields declarations

private static readonly DateTimeOffset Origin = new(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly AsyncLocal<Random?> Seeded = new();

#endregion

internal static Random Current {
get {
Random? random = Seeded.Value;
if (random is null) {
random = new Random(NewSeed());

Check warning on line 28 in FirstClassErrors.Testing/ArbitrarySource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Use a cryptographically strong random number generator.

Check warning on line 28 in FirstClassErrors.Testing/ArbitrarySource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Use a cryptographically strong random number generator.
Seeded.Value = random;
}

return random;
}
}

internal static int NewSeed() {
return System.Guid.NewGuid().GetHashCode();
}

internal static IDisposable UseSeed(int seed) {
Random? previous = Seeded.Value;
Seeded.Value = new Random(seed);

Check warning on line 42 in FirstClassErrors.Testing/ArbitrarySource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Use a cryptographically strong random number generator.

Check warning on line 42 in FirstClassErrors.Testing/ArbitrarySource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Use a cryptographically strong random number generator.

return new SeedScope(previous);
}

internal static int Int() {
return Current.Next(int.MinValue, int.MaxValue);
}

internal static bool Bool() {
return Current.Next(2) == 0;
}

internal static Guid Guid() {
return NewGuid(Current);
}

internal static DateTimeOffset Instant() {
return NewInstant(Current);
}

internal static string Token(string alphabet, int length) {
Random random = Current;
char[] chars = new char[length];
for (int i = 0; i < length; i++) {
chars[i] = alphabet[random.Next(alphabet.Length)];
}

return new string(chars);
}

internal static TEnum Enum<TEnum>()
where TEnum : struct, System.Enum {
TEnum[] values = (TEnum[])System.Enum.GetValues(typeof(TEnum));

return values[Current.Next(values.Length)];
}

internal static TEnum EnumExcluding<TEnum>(params TEnum[] excluded)
where TEnum : struct, System.Enum {
List<TEnum> pool = new();
foreach (TEnum value in (TEnum[])System.Enum.GetValues(typeof(TEnum))) {

Check warning on line 83 in FirstClassErrors.Testing/ArbitrarySource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Loops should be simplified using the "Where" LINQ method

Check warning on line 83 in FirstClassErrors.Testing/ArbitrarySource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Loops should be simplified using the "Where" LINQ method
if (Array.IndexOf(excluded, value) < 0) { pool.Add(value); }
}

return pool[Current.Next(pool.Count)];
}

private static Guid NewGuid(Random random) {
byte[] bytes = new byte[16];
random.NextBytes(bytes);

return new Guid(bytes);
}

private static DateTimeOffset NewInstant(Random random) {
return Origin.AddSeconds(random.Next());
}

#region Nested types

private sealed class SeedScope : IDisposable {

private readonly Random? _previous;
private bool _disposed;

internal SeedScope(Random? previous) {
_previous = previous;
}

public void Dispose() {
if (_disposed) { return; }

_disposed = true;
Seeded.Value = _previous;
}

}

#endregion

}
Loading