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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ section is the GitHub release's notes.
- `Result.CatchAsync` takes a call that returns a `ValueTask`, `stream.ReadAsync(buffer)` without `.AsTask()`; an `async` lambda binds as before ([An exception as an outcome](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/result.md#an-exception-as-an-outcome)).
- `TraverseAsync` on `Option<T>` and `Patch<T>`: an asynchronous check of an optional input or a patch field, with a token form; nothing is called without a value ([Collect many results](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/result.md#collect-many-results)).
- `Fault<TFault>.Cause`: `Fault.Of(reason, detail, exception)` keeps the exception underneath for your log; equality and `ToString()` leave it out, and `ToHttpResult()` hands it to the host as `IFaultFeature.Cause` and `FaultProblem<TFault>.Cause`, never into the problem ([What failed underneath](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-failed-underneath)).
- Testing: every assertion works on a `Task` or `ValueTask` of what it checks, `await service.FindAsync(id).ShouldBeSome()`; a task that throws fails the test with its own exception, and an assertion left without `await` is reported (CMTK0003) ([CodoMetis.TypeKit.Testing](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Testing/README.md)).

## 2.2.0 — 2026-10-18

Expand Down
1 change: 0 additions & 1 deletion design/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ and then to 2.3 when 2.2.0 became a consolidation release ([decision 97](adr/009

- A default value-object key over `ulong` generated as a plain one is, which needs a per-key value
generator a compiled model can write ([decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)).
- Testing: async assertions.

## Gaps, in any 2.x

Expand Down
2 changes: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,5 @@ The analyzers run in test projects as they do everywhere else.
Neither is a `default` assigned to the `out` parameter of a `Try` method.
- **A dropped `Result` is reported (CMTK0003)**, in a test too. Write `_ = ProductCode.Create(input);`
to show the drop is meant.
- **So is an assertion on a task you don't await (CMTK0003).** `service.FindAsync(id).ShouldBeSome();`
without `await` passes whatever the task holds.
17 changes: 17 additions & 0 deletions src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym
// task still held, await held.WaitAsync(t), nothing is dropped.
var passedOn = tasks.PassedOn(dropped);

var reported = false;
foreach (var invocation in Producers(dropped, tasks))
{
// The statement's own call keeps the statement's location, and its type unless a
Expand All @@ -150,9 +151,25 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym
if (DroppedOutcome(type, invocation, symbols, tasks) is not { } outcome) continue;

context.Report(Diagnostic.Create(Rule, (own ? expression : invocation).Syntax.GetLocation(), outcome, invocation.TargetMethod.Name));
reported |= own;
}

// pending.ShouldBeSuccess(); asserts nothing: the assertion fails the test only through its task.
if (!reported && awaited is null && passedOn is IInvocationOperation assertion && IsPendingAssertion(assertion.TargetMethod, tasks))
context.Report(Diagnostic.Create(Rule, expression.Syntax.GetLocation(), assertion.Type?.Name ?? "Task", assertion.TargetMethod.Name));
}

/// <summary>
/// An assertion of CodoMetis.TypeKit.Testing on a <c>Task</c> or <c>ValueTask</c> of the value it
/// asserts on, <c>await service.FindAsync(id).ShouldBeSome()</c>: it awaits the value and fails the
/// test through the task it returns, so that task dropped unawaited passes whatever the value is.
/// Every such assertion is new in 2.3, so reporting its drop breaks no build that worked before.
/// </summary>
private static bool IsPendingAssertion(IMethodSymbol method, Tasks tasks) =>
TypeKitSymbols.IsTestingAssembly(method.ContainingAssembly)
&& (method.ReducedFrom ?? method).Parameters is { Length: > 0 } parameters
&& tasks.Awaited(parameters[0].Type) is not null;

/// <summary>
/// The calls whose values <paramref name="operation"/> drops as one: the call itself, or each
/// branch's call of a conditional or a <c>switch</c> expression, through implicit conversions,
Expand Down
8 changes: 7 additions & 1 deletion src/CodoMetis.TypeKit.Analyzers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ project. The two packages share a version and move together.
|---|---|---|
| CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. Not reported: a default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`), which is a guard, and one assigned to an `out` parameter of a Try method that returns `bool`. |
| CMTK0002 | Error | A type implements `IValue<T>`, `IValidatedValue<,,>` or `IClosedSet<TSelf>`, but the compilation doesn't reference `CodoMetis.TypeKit.Generators`. So nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. |
| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. It counts as dropped as a statement, awaited or not; when its `Task` is converted to a plain `Task`, also where a method group is the conversion; through `Wait`; in a loop that never reads it; and in a branch of a conditional, a `switch` expression or a `??` that picked the call. `_` says you mean the drop. |
| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. It counts as dropped as a statement, awaited or not; when its `Task` is converted to a plain `Task`, also where a method group is the conversion; through `Wait`; in a loop that never reads it; and in a branch of a conditional, a `switch` expression or a `??` that picked the call. Also an assertion of `CodoMetis.TypeKit.Testing` on a task, left without `await`. `_` says you mean the drop. |
| CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core package. |
| CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero isn't reported. |
| CMTK0006 | Warning | A field or auto-property of such a type, in a class, that no initializer, `required` or constructor sets. Also a `required` one that a `[SetsRequiredMembers]` constructor doesn't set. |
Expand Down Expand Up @@ -182,6 +182,12 @@ call's: `await orders.CancelAsync(id).ConfigureAwait(false);` is reported, namin
explicit cast, to `Task` or to the delegate type, says you mean the drop. A `ValueTask<T>` has no
conversion to `ValueTask`.

**Assertions on a task.** An assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask`,
such as `service.FindAsync(id).ShouldBeSome()`, fails the test only through the task it returns.
Left without `await`, the test passes whatever the task holds, and C# warns only in an `async`
method. CMTK0003 reports the line, naming the assertion. The rule knows these assertions by the
package's own assembly, so an extension of yours on a pending result is left alone.

**Collections.** A collection of results is dropped as silently as one result:

```csharp
Expand Down
10 changes: 10 additions & 0 deletions src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ internal sealed class TypeKitSymbols
/// </summary>
private const string GeneratorsAssembly = "CodoMetis.TypeKit.Generators";

/// <summary>
/// The assembly of the test assertions, whose assertions on a pending value fail a test only through
/// the task they return. This is the package's own identity, which a consumer cannot rename.
/// </summary>
private const string TestingAssembly = "CodoMetis.TypeKit.Testing";

private const string Materialize = "Materialize";

private readonly ImmutableArray<INamedTypeSymbol> _valueMarkers;
Expand Down Expand Up @@ -108,6 +114,10 @@ private TypeKitSymbols(
_computeRestriction = ComputeRestriction;
}

/// <summary>Whether <paramref name="assembly"/> is CodoMetis.TypeKit.Testing.</summary>
public static bool IsTestingAssembly(IAssemblySymbol? assembly) =>
assembly is not null && string.Equals(assembly.Name, TestingAssembly, StringComparison.Ordinal);

/// <summary>Whether the compilation references CodoMetis.TypeKit.Generators.</summary>
public bool ReferencesGenerators { get; }

Expand Down
70 changes: 70 additions & 0 deletions src/CodoMetis.TypeKit.Testing/FaultAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,76 @@ public static Fault<T2> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(
public static Fault<T3> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Fault<T1, T2, T3> fault, T3 reason) where T1 : notnull where T2 : notnull where T3 : notnull =>
HeldFaults.Expect<Fault<T1, T2, T3>, T3>(reason, Uninitialized(fault), () => fault.Match<object>(held => held, held => held, held => held));

/// <inheritdoc cref="ShouldHold{T1, T2}(Fault{T1, T2}, T1)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T1>> ShouldHold<[Distinct] T1, [Distinct] T2>(this Task<Fault<T1, T2>> fault, T1 reason) where T1 : notnull where T2 : notnull
{
return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2}(Fault{T1, T2}, T1)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T1>> ShouldHold<[Distinct] T1, [Distinct] T2>(this ValueTask<Fault<T1, T2>> fault, T1 reason) where T1 : notnull where T2 : notnull
{
return (await fault.ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2}(Fault{T1, T2}, T2)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T2>> ShouldHold<[Distinct] T1, [Distinct] T2>(this Task<Fault<T1, T2>> fault, T2 reason) where T1 : notnull where T2 : notnull
{
return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2}(Fault{T1, T2}, T2)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T2>> ShouldHold<[Distinct] T1, [Distinct] T2>(this ValueTask<Fault<T1, T2>> fault, T2 reason) where T1 : notnull where T2 : notnull
{
return (await fault.ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2, T3}(Fault{T1, T2, T3}, T1)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T1>> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Task<Fault<T1, T2, T3>> fault, T1 reason) where T1 : notnull where T2 : notnull where T3 : notnull
{
return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2, T3}(Fault{T1, T2, T3}, T1)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T1>> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this ValueTask<Fault<T1, T2, T3>> fault, T1 reason) where T1 : notnull where T2 : notnull where T3 : notnull
{
return (await fault.ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2, T3}(Fault{T1, T2, T3}, T2)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T2>> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Task<Fault<T1, T2, T3>> fault, T2 reason) where T1 : notnull where T2 : notnull where T3 : notnull
{
return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2, T3}(Fault{T1, T2, T3}, T2)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T2>> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this ValueTask<Fault<T1, T2, T3>> fault, T2 reason) where T1 : notnull where T2 : notnull where T3 : notnull
{
return (await fault.ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2, T3}(Fault{T1, T2, T3}, T3)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T3>> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Task<Fault<T1, T2, T3>> fault, T3 reason) where T1 : notnull where T2 : notnull where T3 : notnull
{
return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason);
}

/// <inheritdoc cref="ShouldHold{T1, T2, T3}(Fault{T1, T2, T3}, T3)"/>
/// <remarks>Awaits <paramref name="fault"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<Fault<T3>> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this ValueTask<Fault<T1, T2, T3>> fault, T3 reason) where T1 : notnull where T2 : notnull where T3 : notnull
{
return (await fault.ConfigureAwait(false)).ShouldHold(reason);
}

private static bool Uninitialized<TComposed>(TComposed fault) where TComposed : struct =>
EqualityComparer<TComposed>.Default.Equals(fault, default);
}
Expand Down
28 changes: 28 additions & 0 deletions src/CodoMetis.TypeKit.Testing/OptionAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,32 @@ public static void ShouldBeNone<T>(this Option<T> option) where T : notnull
if (option.IsSome())
throw new TypeKitAssertionException($"Expected None, but the {Outcomes.Name<Option<T>>()} holds a value (not printed).");
}

/// <inheritdoc cref="ShouldBeSome{T}(Option{T})"/>
/// <remarks>Awaits <paramref name="option"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<T> ShouldBeSome<T>(this Task<Option<T>> option) where T : notnull
{
return (await Outcomes.Pending(option, nameof(option)).ConfigureAwait(false)).ShouldBeSome();
}

/// <inheritdoc cref="ShouldBeSome{T}(Option{T})"/>
/// <remarks>Awaits <paramref name="option"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<T> ShouldBeSome<T>(this ValueTask<Option<T>> option) where T : notnull
{
return (await option.ConfigureAwait(false)).ShouldBeSome();
}

/// <inheritdoc cref="ShouldBeNone{T}(Option{T})"/>
/// <remarks>Awaits <paramref name="option"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task ShouldBeNone<T>(this Task<Option<T>> option) where T : notnull
{
(await Outcomes.Pending(option, nameof(option)).ConfigureAwait(false)).ShouldBeNone();
}

/// <inheritdoc cref="ShouldBeNone{T}(Option{T})"/>
/// <remarks>Awaits <paramref name="option"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task ShouldBeNone<T>(this ValueTask<Option<T>> option) where T : notnull
{
(await option.ConfigureAwait(false)).ShouldBeNone();
}
}
3 changes: 3 additions & 0 deletions src/CodoMetis.TypeKit.Testing/Outcomes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public static void ThrowIfOtherError<TOutcome, TError>(TError actual, TError exp
throw new TypeKitAssertionException($"Expected the error {expected}, but the {Name<TOutcome>()} is the error {actual}.");
}

/// <summary>The task an asynchronous assertion awaits, refused when null: awaited, it was a <see cref="NullReferenceException"/> naming nothing.</summary>
public static Task<T> Pending<T>(Task<T>? task, string paramName) => task ?? throw new ArgumentNullException(paramName, "The assertion was given no task to await.");

/// <summary>
/// <c>Result&lt;Order, OrderFault&gt;</c>, as the type is written in C# from outside its containing
/// types: <c>Billing.Fault</c>, <c>Outer&lt;Int32&gt;.Inner</c>.
Expand Down
42 changes: 42 additions & 0 deletions src/CodoMetis.TypeKit.Testing/PatchAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,48 @@ public static void ShouldBeAbsent<T>(this Patch<T> patch) where T : notnull
throw new TypeKitAssertionException($"Expected Absent, but the {Outcomes.Name<Patch<T>>()} {Was(patch.State)}.");
}

/// <inheritdoc cref="ShouldBeValue{T}(Patch{T})"/>
/// <remarks>Awaits <paramref name="patch"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<T> ShouldBeValue<T>(this Task<Patch<T>> patch) where T : notnull
{
return (await Outcomes.Pending(patch, nameof(patch)).ConfigureAwait(false)).ShouldBeValue();
}

/// <inheritdoc cref="ShouldBeValue{T}(Patch{T})"/>
/// <remarks>Awaits <paramref name="patch"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task<T> ShouldBeValue<T>(this ValueTask<Patch<T>> patch) where T : notnull
{
return (await patch.ConfigureAwait(false)).ShouldBeValue();
}

/// <inheritdoc cref="ShouldBeNull{T}(Patch{T})"/>
/// <remarks>Awaits <paramref name="patch"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task ShouldBeNull<T>(this Task<Patch<T>> patch) where T : notnull
{
(await Outcomes.Pending(patch, nameof(patch)).ConfigureAwait(false)).ShouldBeNull();
}

/// <inheritdoc cref="ShouldBeNull{T}(Patch{T})"/>
/// <remarks>Awaits <paramref name="patch"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task ShouldBeNull<T>(this ValueTask<Patch<T>> patch) where T : notnull
{
(await patch.ConfigureAwait(false)).ShouldBeNull();
}

/// <inheritdoc cref="ShouldBeAbsent{T}(Patch{T})"/>
/// <remarks>Awaits <paramref name="patch"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task ShouldBeAbsent<T>(this Task<Patch<T>> patch) where T : notnull
{
(await Outcomes.Pending(patch, nameof(patch)).ConfigureAwait(false)).ShouldBeAbsent();
}

/// <inheritdoc cref="ShouldBeAbsent{T}(Patch{T})"/>
/// <remarks>Awaits <paramref name="patch"/> first: an exception the task ends with propagates as it is.</remarks>
public static async Task ShouldBeAbsent<T>(this ValueTask<Patch<T>> patch) where T : notnull
{
(await patch.ConfigureAwait(false)).ShouldBeAbsent();
}

private static string Was(PatchState state) => state == PatchState.Value ? "holds a value (not printed)" : $"is {Describe(state)}";

private static string Describe(PatchState state) => state switch
Expand Down
Loading
Loading