Skip to content

Nothing enforces that every CollectionItemFixture builder exposes configureCollection #350

Description

@phmatray

Problem / motivation

#300 (PR #327) gave every item-form builder in CollectionItemFixture a trailing optional
configureCollection callback. All seven now have one:

internal static IFormConfiguration<OrderModel> TextItemForm(
    Action<FieldBuilder<OrderItem, string>>? configure = null,
    Action<CollectionFieldBuilder<OrderModel, OrderItem>>? configureCollection = null) =>

Nothing enforces that the eighth will. That is not a hypothetical: #300 exists only because #282
added the parameter to MultiFieldItemForm and not to its six siblings, and the asymmetry sat there
until someone filed an issue about it. #327 fixed the six by hand and added self-tests — but those
tests name today's seven builders one by one:

CollectionItemFixture.TextItemForm(configureCollection:)
CollectionItemFixture.NumericItemForm(configureCollection:)
// …five more, enumerated

A builder added tomorrow appears in none of them, so it can ship without the parameter and the suite
stays green. The fix for #300 is therefore a snapshot, not an invariant — the same class of
gap #297 closed for the fixture's models and this issue closes for its builders.

This repo already has the idiom. CollectionItemShapeGuard (#297, PR #306) reflects over the test
assembly and fails the build when a suite re-declares a shape the fixture provides;
FormCraft.UnitTests/Extensions/NativeRequiredBuilderTests.cs reflects over a builder surface; the
FormCraft.UnitTests/Ci/* suites scan workflow source. A conformance guard here is the house style,
not a new mechanism.

Proposed solution

A guard mirroring CollectionItemShapeGuard's shape — a record offence type with a readable
ToString(), a static class exposing FindOffenders(members, allowed), and a test class holding both
the [Fact] that runs it over the real fixture and the unit tests that prove the guard itself bites.

It asserts two things per builder, because the signature alone is only half the contract:

  1. Shape — every IFormConfiguration<>-returning member of CollectionItemFixture declares a
    last, optional parameter of type Action<CollectionFieldBuilder<TModel, TItem>> whose
    TModel matches the member's own return type. Last and optional are load-bearing: they are
    what let configureCollection exists on only one CollectionItemFixture builder #300 be purely additive, and a future builder that inserts the parameter anywhere else
    silently rebinds every positional call site.
  2. Behaviour — the callback actually reaches the collection and runs after the builder's own
    WithLabel/WithItemForm. Checkable without rendering: invoke the member reflectively with nulls
    for every field callback and a configureCollection that sets a sentinel label, then assert the
    returned configuration carries the sentinel. A builder that accepts the parameter and drops it, or
    invokes it before its own label, fails.

Point 2 is what makes this worth building rather than a lint. AllowReorder() — the obvious probe — is
an order-insensitive setter, so it cannot distinguish "callback runs last" from "callback runs first";
overriding the label can only succeed if the caller runs last. #327 pinned that for the current seven
in Each_Item_Forms_Collection_Callback_Should_Run_After_The_Fixtures_Own_Configuration; the guard
generalises it to every future builder.

Alternatives considered

  • Leave it to code review. Zero work. Rejected on evidence: review is exactly what missed it in
    Finish adopting the collection-item fixture: five suites still copy its models under other names #282, and the miss survived long enough to become configureCollection exists on only one CollectionItemFixture builder #300. A convention that costs an issue every time
    it is broken is not being enforced.
  • Scan the source text instead of reflecting (the FormCraft.UnitTests/Ci/* idiom). Rejected: those
    suites scan YAML, where there is no type system to ask. Here the members are typed and loaded, so
    reflection answers the question exactly, while a regex over .cs would re-implement C# parameter
    parsing to reach a worse answer.
  • Assert only the signature, not the behaviour. Cheaper, and the natural first cut. Rejected as
    half a guard: a builder can take configureCollection and never invoke it, which type-checks, passes
    a signature guard, and silently gives every consuming suite an unconfigured collection.
  • Extend CollectionItemShapeGuard rather than adding a sibling. Rejected: that guard answers "does
    any suite re-declare a model shape?" over types in the assembly; this one answers "is the fixture's
    own builder surface uniform?" over members of one class. Merging them would give one class two
    unrelated inputs and two unrelated offence vocabularies.

Area

FormCraft.ForMudBlazor.UnitTests — test infrastructure


Follow-up from #327. Related: #300, #282, #297, #343

⚠️ Sequencing with #343. That issue proposes extracting the framework-agnostic half of
CollectionItemFixture (models, factories, and the builders — everything that names no MudBlazor
type) into a shared test-support project so the Fluent UI suite can reach it. This guard reflects
over exactly those builders, so it moves with them. Neither blocks the other: land whichever comes
first and relocate the guard alongside the fixture. Worth doing #343 first if both are scheduled
together, to avoid writing the guard twice.

🧠 Brainstorm

Problem / context

CollectionItemFixture is the shared collection-field fixture for FormCraft.ForMudBlazor.UnitTests.
It exists because eleven suites each carried their own near-identical models and form configurations,
which drifted (#205, #258, #282). Its value is that every suite gets the same shape, so a change to
the render path is asserted once rather than eleven times.

That value depends on the builders being uniform. They are not uniform by construction — they are
seven hand-written static methods that happen to agree, and the project has already watched them stop
agreeing once. #297 recognised the same risk for the fixture's models and answered it with a build-time
guard rather than a convention; the builders' parameter surface got no such treatment.

The concrete surface at stake: TextItemForm, NumericItemForm, DateItemForm, BooleanItemForm,
DecimalItemForm, MultiFieldItemForm, RootFieldAndItemForm.

Approaches

A. Reflection conformance guard over the fixture's members — signature and behaviour.
Pros: answers the real question exactly (the members are typed and loaded); catches every future
builder for free; mirrors CollectionItemShapeGuard so a reader already knows the shape; the
behaviour half also generalises #327's invoke-order test, which currently enumerates by name.
Cons: reflectively invoking a generic-delegate parameter needs a small MakeGenericMethod helper —
about fifteen lines of fiddly-but-standard code that itself wants a test.

B. Signature-only reflection guard.
Pros: half the code, no reflective invocation, no helper. Cons: a builder that accepts the
parameter and never invokes it passes. That is a plausible mistake — it is what a copy-paste of the
signature without the body produces — and it fails silently in every consuming suite rather than
loudly here.

C. Source-text scan of CollectionItemFixture.cs.
Pros: no reflection at all. Cons: re-implements C# parameter-list parsing with regexes to answer a
question the type system already knows; brittle against formatting; cannot check behaviour at all.

D. Do nothing; rely on review.
Pros: free. Cons: this is the status quo that produced #300.

Recommendation

A. The behaviour half is the part that earns the issue — a signature guard alone would have caught
#282's omission, but not the more likely future failure of a builder that declares the parameter and
forgets to invoke it. The reflective invocation is confined to one helper with its own negative
control, which is the same containment CollectionItemShapeGuard uses for its own fiddly parts.

📋 Spec

Goal

Adding a new item-form builder to CollectionItemFixture without a working configureCollection
callback fails the build, with a message naming the member and what is wrong.

Scope

  • A CollectionItemBuilderSurfaceGuard in FormCraft.ForMudBlazor.UnitTests/Fields/, exposing
    FindOffenders(IEnumerable<MethodInfo> members, IReadOnlySet<MethodInfo>? allowed = null).
  • A BuilderSurfaceOffence record with a readable ToString(), mirroring ShapeOffence.
  • CollectionItemBuilderSurfaceGuardTests — the [Fact] running the guard over the real fixture, plus
    unit tests proving the guard flags each violation class and clears a conforming member.

Non-goals

Surface

flowchart TD
    A["CollectionItemFixture members<br/>returning IFormConfiguration&lt;&gt;"] --> B{"last parameter"}
    B -- "missing / not last / not optional" --> X["BuilderSurfaceOffence"]
    B -- "wrong closed type<br/>(TModel mismatch)" --> X
    B -- "conforms" --> C["invoke reflectively:<br/>nulls + sentinel WithLabel"]
    C -- "sentinel absent" --> X
    C -- "sentinel present" --> D["conforming"]
    X --> E["[Fact] fails, naming member + reason"]
Loading

Validation rules

  • Shape offences, one per member: no trailing Action<CollectionFieldBuilder<,>>; the parameter
    exists but is not last; it is not optional; its TModel does not match the member's
    IFormConfiguration<TModel> return type.
  • Behaviour offence: invoking the member with a configureCollection that calls
    WithLabel(sentinel) yields a configuration in which no field carries the sentinel — covering both
    "callback dropped" and "callback invoked before the builder's own WithLabel".
  • The guard must be proven to bite. Non-conforming fakes live in a nested private type inside the
    test class so CollectionItemShapeGuard does not flag them as re-declared shapes — nested types are
    already classified as copies rather than shared shapes, so keep them nested and free of collection
    properties, or add them to that guard's allowlist with a reason.
  • Run over the seven real builders, the guard reports zero offenders on dev as of test(mudblazor): let every item-form builder configure its collection (#300) #327.
  • ./build.sh Format clean; dotnet build -c Release --no-incremental → 0 warnings; ./build.sh Test
    green.

Edge cases

  • RootFieldAndItemForm takes three callbacks, two of them field-level. "Last parameter" is the
    rule, not "second parameter" — do not encode a position count.
  • MultiFieldItemForm takes five. Same rule; it is also the member whose parameter predates the
    convention (Finish adopting the collection-item fixture: five suites still copy its models under other names #282), so it must not be special-cased or allowlisted.
  • NumericItemForm and BooleanItemForm share BasketModel.Lines. Their TModel is identical,
    so a TModel-match check cannot tell them apart — that is fine, it is not trying to; but do not
    "de-duplicate by model type" when enumerating members or one of the two vanishes from the guard.
  • Reflective invocation passes null for every field callback. Every builder already treats those
    as optional (configure?.Invoke(…)), so this is safe — but a future builder that dereferences one
    unconditionally would throw here rather than report an offence. Catch TargetInvocationException
    and turn it into an offence with the inner exception's message, rather than letting the [Fact]
    die with an unhelpable stack.
  • The sentinel must not collide with a real label. Use something obviously synthetic
    ("__configureCollection_sentinel__"), not "Renamed".
  • Non-builder members must be excluded. NewOrder, NewBasket, RenderItemForm etc. do not
    return IFormConfiguration<>; filter on the return type, not on the name.

Assumptions

🛠️ Implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: a build-time guard asserting every CollectionItemFixture item-form builder declares a
trailing optional configureCollection and actually invokes it last.

Architecture: test project only (FormCraft.ForMudBlazor.UnitTests). No production file may be
modified.
Mirror CollectionItemShapeGuard's split — offence record + static guard with
FindOffenders + a test class holding the real-fixture [Fact] and the guard's own unit tests.

Tech stack: .NET 10 test project (libraries multi-target net8.0;net10.0), xUnit v3 +
Microsoft.Testing.Platform, Shouldly. No bUnit needed — the behaviour check inspects the built
IFormConfiguration<>, it does not render.

Global constraints:

  • Base branch dev; commit as Philippe Matray <phmatray@gmail.com>; conventional commits; PR title
    ends (#<issue>) and is linted by pr-title-lint.yml.
  • Two independent CI gates since The .editorconfig's style rules are enforced nowhere, so 574 violations have accumulated #301./build.cmd Format (wraps
    dotnet format --verify-no-changes, runs before Test) and ./build.cmd Test. Satisfy both.
    ⚠️ dotnet format applying can write literal <<<<<<< TODO: Unmerged change from project …
    blocks into .cs files on this multi-targeted repo; after any apply run
    grep -rl '<<<<<<< TODO' --include='*.cs' . and hand-resolve.
  • TreatWarningsAsErrors=true (NoWarn=CS1591;CS8620) — any compiler warning fails the build.
  • dotnet test --filter is inert (MTP0001). Per-class filtering runs the test host directly and
    requires a prior dotnet build -c Release because it does not build:
    FormCraft.ForMudBlazor.UnitTests/bin/Release/net10.0/FormCraft.ForMudBlazor.UnitTests --filter-class <FQN>.
    Add --list-tests --no-ansi --no-progress to count rather than run. Treat Zero tests ran (exit 8)
    as a hard stop.
  • Record per-suite test counts before starting and assert each is unchanged after.

Task 1: The offence record and the shape half of the guard

Files: create Fields/CollectionItemBuilderSurfaceGuard.cs, Fields/CollectionItemBuilderSurfaceGuardTests.cs.

Interfaces: record BuilderSurfaceOffence(MethodInfo Member, string Reason) with ToString(); internal static IReadOnlyList<BuilderSurfaceOffence> FindOffenders(IEnumerable<MethodInfo> members, IReadOnlySet<MethodInfo>? allowed = null); internal static IEnumerable<MethodInfo> ItemFormBuilders(Type fixture) filtering on an IFormConfiguration<> return type.

  • Step 1: Record the per-suite count for CollectionItemFixtureTests and the project total from dotnet test -c Release.
  • Step 2: Write failing unit tests for the shape rules against nested non-conforming fakes: no trailing callback; callback present but not last; callback not optional; callback's TModel mismatched. Plus a conforming fake that must yield no offence — the negative control without which the guard could return "everything is an offence" and pass.
  • Step 3: Run the per-class filter → FAIL (guard type does not exist).
  • Step 4: Implement BuilderSurfaceOffence and FindOffenders' shape checks, plus ItemFormBuilders filtering on the return type (never on the member name).
  • Step 5: Run the per-class filter → PASS.
  • Step 6: Commit: test(mudblazor): guard the item-form builders' collection-callback signature.

Task 2: The behaviour half — the callback is invoked, and invoked last

Files: modify Fields/CollectionItemBuilderSurfaceGuard.cs, Fields/CollectionItemBuilderSurfaceGuardTests.cs.

Interfaces: a private static bool ReachesCollection(MethodInfo member, out string? reason) building a closed Action<CollectionFieldBuilder<TModel, TItem>> via MakeGenericMethod, invoking the member with nulls elsewhere, and looking for the sentinel label on the built configuration.

  • Step 1: Write the failing tests: a fake that declares the parameter and never invokes it → offence; a fake that invokes it before its own WithLabel → offence (the sentinel is overwritten); a conforming fake → none.
  • Step 2: Run the per-class filter → FAIL.
  • Step 3: Implement the reflective invocation. Use an obviously synthetic sentinel ("__configureCollection_sentinel__"). Wrap the Invoke so a TargetInvocationException becomes an offence carrying the inner message rather than killing the run.
  • Step 4: Run the per-class filter → PASS.
  • Step 5: Commit: test(mudblazor): assert each builder actually invokes its collection callback last.

Task 3: Run it over the real fixture and document the contract

Files: modify Fields/CollectionItemBuilderSurfaceGuardTests.cs, Fields/CollectionItemFixture.cs (doc comment only).

Interfaces: none new.

  • Step 1: Add the [Fact] running FindOffenders(ItemFormBuilders(typeof(CollectionItemFixture))) and asserting it is empty, with an Additional Info message telling a future author what to do — mirroring CollectionItemShapeGuardTests' wording.
  • Step 2: Run dotnet test -c Release → PASS with zero offenders across all seven builders; confirm every previously-recorded per-suite count is unchanged apart from the new tests.
  • Step 3: Mutation-check that the [Fact] bites: temporarily delete configureCollection from DecimalItemForm and confirm it goes red naming that member; restore and re-run green.
  • Step 4: Point CollectionItemFixture's class summary at the guard, so the ordering contract it documents names the test that enforces it.
  • Step 5: Confirm git diff --name-only dev...HEAD lists no file outside FormCraft.ForMudBlazor.UnitTests/.
  • Step 6: Run ./build.sh Format → clean, dotnet build -c Release --no-incremental → 0 warnings, then ./build.sh Test → green.
  • Step 7: Commit: test(mudblazor): fail the build when an item-form builder skips its collection callback.

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:lowNice to havetype:testTest improvements or additions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions