diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index 0068461..11474a9 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -16,6 +16,6 @@ jobs: 8.0.x 9.0.x 10.0.x - 11.0.x + 11.0.100-preview.6.26359.118 runCdk: false secrets: inherit diff --git a/Directory.Build.props b/Directory.Build.props index 118d2fc..ea1fe49 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -30,6 +30,36 @@ False + + + + + false diff --git a/Directory.Packages.props b/Directory.Packages.props index 32a2e3d..a2e872b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,19 @@ a directly-referenced/newer Microsoft.Testing.Platform and throws TypeLoadException (IDataConsumer) at test-host startup. --> + + diff --git a/docs/packages/compono-tunit.md b/docs/packages/compono-tunit.md index fa114c8..529ebb2 100644 --- a/docs/packages/compono-tunit.md +++ b/docs/packages/compono-tunit.md @@ -21,10 +21,10 @@ composer's own `Create()`). ## What it gives you (today) -This is the first, method-parameter-only slice of `Compono.TUnit` — see +PLAN-0040 Phase 0/1 have shipped — see [ADR-0040](../adr/0040-compono-tunit-package-design.md) for the full design -and [PLAN-0040](../plans/0040-compono-tunit-package-design.md) for what -ships in which phase. +and [PLAN-0040](../plans/0040-compono-tunit-package-design.md) for phase +status. - **`[Compose]`** — every method parameter is composed: @@ -47,10 +47,35 @@ ships in which phase. property (`TestContext.Current.Metadata.TestDetails.CustomProperties`), and a *composition* failure's message includes the seed that produced it. -`[Compose]` and `[Compose]` — profile -selection and profile configuration arguments, matching -[`Compono.XunitV3`](compono-xunitv3.md#profile-configuration-arguments)'s -own shape — are not part of this first slice; see PLAN-0040's later phases. +- **`[Compose]`** — applies a fixed, default-constructed + profile to the row's `Composer`, matching + [`Compono.XunitV3`](compono-xunitv3.md)'s own `ComposeAttribute` + exactly: + + ```csharp + [Test] + [Compose] + public async Task Saves_order([Shared] IOrderRepository repository, CreateOrderHandler handler, PlaceOrder command) + { + await handler.Handle(command); + await repository.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + } + ``` + +- **`[Compose]`** — profile selection and profile + configuration arguments, matching + [`Compono.XunitV3`](compono-xunitv3.md#profile-configuration-arguments)'s + own shape exactly, including its once-per-attribute-instance reflection + bound (`ConfigProfileBinder`, mirrored into `Compono.TUnit.Binding`). + +## Hard constraint: one Compose-family attribute per method + +`[Compose]`, `[Compose]`, and `[Compose]` are +all `ComposeAttribute` subclasses. `[AttributeUsage(AllowMultiple = false)]` +is enforced per exact attribute type by the compiler, not across the +family — stacking two *different* Compose-family attributes on one method +compiles, but `BindingPlan.ValidateSignature` rejects it at +data-generation time with a clear `CompositionException`. ## Native AOT @@ -63,6 +88,16 @@ own shape — are not part of this first slice; see PLAN-0040's later phases. the real `ComposeAttribute.GetDataRowsAsync` through both a custom composed type and a provider-resolved leaf type. +`[Compose]`'s `ConfigProfileBinder` needed its own +separate AOT gate (ADR-0041 Amendment 1) — `ConstructorInfo.Invoke`-based +construction on a closed generic type argument is **not** safe by default +under trimming; the trimmer strips a type's public constructors unless +something tells it they're reachable. `ConfigProfileBinder` and +`ComposeAttribute` carry +`[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]` +annotations end to end to fix this, verified by the same AOT smoke test +exercising `[Compose]` alongside the plain form. + ## Disposal TUnit disposes a `[Compose]`-composed **root** method argument itself, @@ -79,21 +114,8 @@ section for the full reasoning. ## What it deliberately doesn't do -- **Stacking distinct Compose-family attributes on one method is not - currently detected or rejected.** `Compono.XunitV3`'s equivalent throws a - clear `CompositionException` for this shape - ([`Compono.XunitV3`](compono-xunitv3.md#what-it-deliberately-doesnt-do)) - - `Compono.TUnit` does not do the same validation yet: `BindingPlan.Build` - only ever sees TUnit's own `MethodMetadata`, which doesn't expose the - method's attribute list the way a raw `MethodInfo` does, so this is a - known v1 gap, tracked in - [PLAN-0040](../plans/0040-compono-tunit-package-design.md)'s Phase 1 - checklist ("Stacked Compose-family attribute validation"), not a - guarantee. Don't stack Compose-family attributes on one - TUnit test method - the result is undefined, not a documented failure - mode. - **No fixture object** — configuration lives in a profile, applied per - test method, not a shared mutable object (once profile support ships). + test method, not a shared mutable object. ## Next diff --git a/docs/plans/0040-compono-tunit-package-design.md b/docs/plans/0040-compono-tunit-package-design.md index 7b62414..14993c9 100644 --- a/docs/plans/0040-compono-tunit-package-design.md +++ b/docs/plans/0040-compono-tunit-package-design.md @@ -416,13 +416,13 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. ### Phase 1: Profile variants, their own tests and docs -**Status:** Not Started +**Status:** Done -- [ ] `ComposeAttribute : ComposeAttribute` — `new()`-constrained +- [x] `ComposeAttribute : ComposeAttribute` — `new()`-constrained profile type parameter, mirroring `Compono.XunitV3`'s `ComposeAttribute` exactly (method-level only, matching that package's own original scope decision). -- [ ] `ComposeAttribute : ComposeAttribute` — profile +- [x] `ComposeAttribute : ComposeAttribute` — profile built from attribute-constructor-supplied config args, mirroring `Compono.XunitV3`'s `ComposeAttribute` (ADR-0036) exactly, including its once-per-attribute-instance @@ -444,7 +444,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. independent constructor and storage, duplicated from `Compono.XunitV3`'s exact shape, not shared with the other generic form. -- [ ] Stacked Compose-family attribute validation: reject a test method +- [x] Stacked Compose-family attribute validation: reject a test method carrying more than one of `[Compose]`/`[Compose]`/ `[Compose]` — `AllowMultiple = false` is enforced per exact attribute type by the compiler, not across the family, so @@ -462,7 +462,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. requirement. Without it, TUnit runs both attributes' data sources independently and produces duplicate/conflicting rows despite ADR-0040 promising full `Compono.XunitV3` parity. -- [ ] `test/Compono.TUnit.Tests`: profile-binding unit/integration +- [x] `test/Compono.TUnit.Tests`: profile-binding unit/integration coverage (`ComposeAttribute`, `ComposeAttribute` config binding) plus inline-values-combined-with-a-profile coverage (Phase 0 already covers inline values alone; this phase @@ -473,7 +473,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. (`[Compose]` + `[Compose]`, `[Compose]` + `[Compose]`, etc.) — the case Phase 0 alone can't exercise, since it needs a second Compose-family type to exist. -- [ ] The full Goal-section scenario, run for real under TUnit: `[Shared] +- [x] The full Goal-section scenario, run for real under TUnit: `[Shared] IOrderRepository` composed via `[Compose]`, `UseNSubstitute()` wired through the profile, `repository` reused inside `handler`'s own composed constructor parameter — the @@ -481,12 +481,12 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. reproduced under TUnit for real (this needs `Compono.NSubstitute` as an additional test dependency, matching how the xUnit v3 sample project references it). -- [ ] Extend `docs/packages/compono-tunit.md` with the profile-attribute +- [x] Extend `docs/packages/compono-tunit.md` with the profile-attribute sections (`[Compose]`/`[Compose]`, inline values). -- [ ] Extend `skills/compono/references/tunit.md` with profile-attribute +- [x] Extend `skills/compono/references/tunit.md` with profile-attribute guidance, matching `xunit-v3.md`'s equivalent sections. -- [ ] **Native AOT gate on `ConfigProfileBinder` — a release requirement, +- [x] **Native AOT gate on `ConfigProfileBinder` — a release requirement, not optional polish (ADR-0041 Amendment 1).** `[Compose]`'s own `ConfigProfileBinder` needs the identical AOT analysis ADR-0041 already performed for row-binding dispatch: @@ -838,3 +838,75 @@ Then the two tasks this phase's own text had left blocked on that merge: Both tasks this phase's text had left open are now done - Phase 0 is complete pending PR review/merge. + +**Phase 1 implementation (2026-08-12)**: `ComposeAttribute` and +`ComposeAttribute` (+ `ConfigProfileBinder`) both ported +byte-for-byte from `Compono.XunitV3`, adapted to `Compono.TUnit`'s base +class shape. Stacked-attribute rejection added to +`BindingPlan.ValidateSignature`, resolving the method's real `MethodInfo` +via a parameter's `ReflectionInfo.Member` (or a fallback lookup for a +zero-parameter method - see the PR #76 Codex review note below for why +that fallback isn't a plain `Type.GetMethod(name, Type.EmptyTypes)` call) +and counting `ComposeAttribute`-derived attributes on it. + +**PR #76 Codex review, round 1 (2026-08-12)**: 2 findings, both confirmed +real, fixed in `e36facd`: +- The zero-parameter `ResolveMethodInfo` fallback originally used + `Type.GetMethod(name, Type.EmptyTypes)`, which matches by parameter + *types* only, not generic arity - a class declaring both a + zero-parameter `Run()` and a zero-parameter-but-generic `Run()` + threw `AmbiguousMatchException` instead of reaching the existing + generic-method `CompositionException`, crashing `BindingPlan.Build` + entirely for that shape. Fixed by filtering `GetMethods()` on name, + zero declared parameters, *and* `testInformation.GenericTypeCount` + together. Added `AmbiguousZeroParameterMethod()`/ + `AmbiguousZeroParameterMethod()` fixtures and two regression tests. +- `src/Compono.TUnit/Compono.TUnit.csproj`'s NuGet description still said + the profile variants "ship in a later phase" despite this same PR + shipping them - restored to describe the full family. + +**PR #76 Codex review, round 2 (2026-08-12)**: 1 finding, confirmed real, +fixed in the same commit as this note - a code comment on +`ValidateSignature` (and this Notes entry, above) still described the +*original*, buggy `Type.EmptyTypes`-only reasoning after the arity-aware +fix replaced it, contradicting the actual implementation and risking +someone "simplifying" `ResolveMethodInfo` back to the broken version on a +future read. Both corrected to point at `ResolveMethodInfo`'s real, +three-part filter. + +**Native AOT gate on `ConfigProfileBinder` (ADR-0041 Amendment 1) found a +real gap, not a formality.** Extending the Phase 0 AOT smoke test to also +exercise `[Compose]` failed at runtime on first try: +`CompositionException: 'ProfileConfig' must have exactly one public +constructor to be used as profile configuration, but has 0` — the trimmer +strips a closed generic type argument's public constructors by default +unless something tells it they're reachable; "`ConstructorInfo.Invoke` on +an already-known/non-generic `Type` is likely lower-risk than +`MakeGenericMethod`" (this plan's own original hedge) was directionally +right but not sufficient on its own. Fixed with +`[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]` +annotations on `ConfigProfileBinder`'s `Type`/generic-type-parameter +inputs and on `ComposeAttribute`'s own `TProfile`/ +`TConfig` type parameters — re-running the same `dotnet publish -c Release +-p:PublishAot=true -r osx-arm64 --self-contained true` + run confirmed +both `[Compose]` and `[Compose]` now pass, with zero +trim warnings from `Compono.TUnit`'s own code (`-p:TrimmerSingleWarn=false` +still shows only the same two pre-existing harness-only `IL2072` warnings +from Phase 0). + +The Goal-section scenario now runs for real: +`test/Compono.TUnit.SampleTests/NSubstituteTests.cs` mirrors +`Compono.XunitV3.SampleTests/NSubstituteTests.cs` exactly, added +`Compono.NSubstitute` to that project's local-feed pack chain (relies on +`PackageReference`'s default transitive-dependency flow for `NSubstitute` +itself, same as the xUnit v3 sibling - no explicit `NSubstitute` +`PackageReference` needed). Passed under a real TUnit runner across all +four TFMs. + +Docs (`docs/packages/compono-tunit.md`, +`skills/compono/references/tunit.md`) updated in the same change - the +former "not part of this slice"/"stacking is undefined" language replaced +with the shipped shape and the real stacked-attribute rejection behavior. + +Phase 1 is complete - every task checked off, full solution build/test +green. diff --git a/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute.md b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute.md index 719ea11..9c8219b 100644 --- a/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute.md +++ b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute.md @@ -16,6 +16,10 @@ public class ComposeAttribute : TUnit.Core.UntypedDataSourceGeneratorAttribute, Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object') → [System\.Attribute](https://learn.microsoft.com/en-us/dotnet/api/system.attribute 'System\.Attribute') → `TUnit.Core.AsyncUntypedDataSourceGeneratorAttribute` → `TUnit.Core.UntypedDataSourceGeneratorAttribute` → ComposeAttribute +Derived +↳ [ComposeAttribute<TProfile,TConfig>](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\') +↳ [ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\') + Implements `TUnit.Core.Interfaces.ITestDiscoveryEventReceiver`, `TUnit.Core.Interfaces.IEventReceiver` ### Remarks diff --git a/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.ComposeAttribute(object[]).md b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.ComposeAttribute(object[]).md new file mode 100644 index 0000000..c81556b --- /dev/null +++ b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.ComposeAttribute(object[]).md @@ -0,0 +1,21 @@ +#### [Compono\.TUnit](index.md 'index') +### [Compono\.TUnit](Compono.TUnit.md 'Compono\.TUnit').[ComposeAttribute<TProfile,TConfig>](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\') + +## ComposeAttribute\(object\[\]\) Constructor + +Creates a [ComposeAttribute<TProfile,TConfig>](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\')\. + +```csharp +public ComposeAttribute(params object?[] configArguments); +``` +#### Parameters + + + +`configArguments` [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object')[\[\]](https://learn.microsoft.com/en-us/dotnet/api/system.array 'System\.Array') + +Profile configuration arguments, bound positionally to [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig')'s single +public constructor \- an entirely separate binding target from this attribute family's ordinary +inline values; every test method parameter is composed in full regardless of what's supplied +here\. See the type\-level remarks for why each argument should use the strongest attribute\-legal +type available rather than a bare string\. \ No newline at end of file diff --git a/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md new file mode 100644 index 0000000..2f57f51 --- /dev/null +++ b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md @@ -0,0 +1,70 @@ +#### [Compono\.TUnit](index.md 'index') +### [Compono\.TUnit](Compono.TUnit.md 'Compono\.TUnit') + +## ComposeAttribute\ Class + +Composes a TUnit test method's parameters through Compono, applying a profile built from +\profile configuration arguments\ known at this attribute's call site \- a distinct concept +from this attribute family's ordinary inline values \([ComposeAttribute\(object\[\]\)](Compono.TUnit.ComposeAttribute.ComposeAttribute(object[]).md 'Compono\.TUnit\.ComposeAttribute\.ComposeAttribute\(object\[\]\)')\), +which bind to the test method's own parameters instead\. This constructor never binds to the test +method's parameters at all; every one of them is composed in full\. [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig') is +constructed positionally from this attribute's own constructor arguments, then +[TProfile](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile') is constructed from that [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig') instance and +applied via [AddProfile\(ICompositionProfile\)](../Compono/Compono.CompositionBuilder.AddProfile.md#Compono.CompositionBuilder.AddProfile(Compono.ICompositionProfile) 'Compono\.CompositionBuilder\.AddProfile\(Compono\.ICompositionProfile\)') \- equivalent to +`Composer.Create(builder => builder.AddProfile(new TProfile(new TConfig(...))))`\. See +`docs/adr/0036-parameterized-composition-profile-selection.md` for the full design, including +why this exists as a separate attribute rather than overloading +[ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\')'s own inline\-value constructor argument\. Mirrors +`Compono.XunitV3.ComposeAttribute{TProfile, TConfig}` exactly\. + +```csharp +public sealed class ComposeAttribute : Compono.TUnit.ComposeAttribute + where TProfile : Compono.ICompositionProfile +``` +#### Type parameters + + + +`TProfile` + +The profile to construct and apply\. Must have exactly one public constructor accepting exactly one +[TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig')\-typed parameter \- no `new()` constraint, unlike +[ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\'), since this form is never default\-constructed\. + + + +`TConfig` + +The type this attribute's constructor arguments bind to, positionally, against its own single +public constructor\. Prefer strongly\-typed, attribute\-legal values for its constructor parameters \- +an [enum](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/enum 'https://docs\.microsoft\.com/en\-us/dotnet/csharp/language\-reference/keywords/enum') for a finite choice, [System\.Type](https://learn.microsoft.com/en-us/dotnet/api/system.type 'System\.Type') via `typeof(...)` for a CLR +type, a plain [bool](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/bool 'https://docs\.microsoft\.com/en\-us/dotnet/csharp/language\-reference/keywords/bool')/numeric/string value where that already carries the real +meaning \- over loosely\-typed primitives standing in for something more specific\. +`params object?[]` is a binding mechanism forced by C\#'s attribute\-argument\-must\-be\-a\- +compile\-time\-constant rule, not a license to design [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig') around magic +strings\. + +Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object') → [System\.Attribute](https://learn.microsoft.com/en-us/dotnet/api/system.attribute 'System\.Attribute') → `TUnit.Core.AsyncUntypedDataSourceGeneratorAttribute` → `TUnit.Core.UntypedDataSourceGeneratorAttribute` → [ComposeAttribute](Compono.TUnit.ComposeAttribute.md 'Compono\.TUnit\.ComposeAttribute') → ComposeAttribute\ + +### Remarks +Unlike [ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\')'s compile\-time\-enforced `new()` constraint, an +unsupported [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig')/[TProfile](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile') constructor shape \(not +exactly one public constructor on [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig'); no exactly\-one\- +[TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig')\-parameter public constructor on [TProfile](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile')\) is a +deterministic runtime [CompositionException](../Compono/Compono.CompositionException.md 'Compono\.CompositionException'), not a compile error \- there is no C\# +generic constraint that expresses "has a constructor accepting exactly this type\." Both constructor +lookups, and the actual construction, are reflection \(`Compono.TUnit.Binding.ConfigProfileBinder`\) \- bounded +and cached to once per attribute instance by this attribute family's existing +[System\.Lazy<>](https://learn.microsoft.com/en-us/dotnet/api/system.lazy-1 'System\.Lazy\`1')\-backed [Composer](../Compono/Compono.Composer.md 'Compono\.Composer') caching \(`Compono.TUnit.ComposeAttribute<>.ApplyProfile(Compono.CompositionBuilder)` is only ever +invoked from inside that lazy initializer\), never on the repeated per\-row data\-source path\. +[TProfile](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile') and [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig') both carry +[System\.Diagnostics\.CodeAnalysis\.DynamicallyAccessedMembersAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.dynamicallyaccessedmembersattribute 'System\.Diagnostics\.CodeAnalysis\.DynamicallyAccessedMembersAttribute')\([System\.Diagnostics\.CodeAnalysis\.DynamicallyAccessedMemberTypes\.PublicConstructors](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.dynamicallyaccessedmembertypes.publicconstructors 'System\.Diagnostics\.CodeAnalysis\.DynamicallyAccessedMemberTypes\.PublicConstructors')\) +\- required, not decorative: a real Native AOT publish\-and\-run proof \(ADR\-0041 Amendment 1\) showed +the trimmer strips a closed generic argument's public constructors by default, since nothing in an +unannotated `Type.GetConstructors()` call site tells it they're reachable \- `ConfigProfileBinder` +failed at runtime with "has 0" public constructors on a type that plainly has one, until these +annotations were added at every generic parameter/`Type`\-typed parameter along the call chain\. + +| Constructors | | +| :--- | :--- | +| [ComposeAttribute\(object\[\]\)](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.ComposeAttribute(object[]).md 'Compono\.TUnit\.ComposeAttribute\\.ComposeAttribute\(object\[\]\)') | Creates a [ComposeAttribute<TProfile,TConfig>](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\')\. | diff --git a/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.ComposeAttribute(object[]).md b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.ComposeAttribute(object[]).md new file mode 100644 index 0000000..cd4c05f --- /dev/null +++ b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.ComposeAttribute(object[]).md @@ -0,0 +1,18 @@ +#### [Compono\.TUnit](index.md 'index') +### [Compono\.TUnit](Compono.TUnit.md 'Compono\.TUnit').[ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\') + +## ComposeAttribute\(object\[\]\) Constructor + +Creates a [ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\')\. + +```csharp +public ComposeAttribute(params object?[] inlineValues); +``` +#### Parameters + + + +`inlineValues` [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object')[\[\]](https://learn.microsoft.com/en-us/dotnet/api/system.array 'System\.Array') + +Values supplied positionally, left\-to\-right from the test method's first parameter \- see +[ComposeAttribute\(object\[\]\)](Compono.TUnit.ComposeAttribute.ComposeAttribute(object[]).md 'Compono\.TUnit\.ComposeAttribute\.ComposeAttribute\(object\[\]\)')\. \ No newline at end of file diff --git a/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.md b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.md new file mode 100644 index 0000000..7a017c8 --- /dev/null +++ b/docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.md @@ -0,0 +1,34 @@ +#### [Compono\.TUnit](index.md 'index') +### [Compono\.TUnit](Compono.TUnit.md 'Compono\.TUnit') + +## ComposeAttribute\ Class + +Composes a TUnit test method's parameters through Compono, with [TProfile](Compono.TUnit.ComposeAttribute_TProfile_.md#Compono.TUnit.ComposeAttribute_TProfile_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile') +applied to the underlying [Composer](../Compono/Compono.Composer.md 'Compono\.Composer') \- equivalent to +`Composer.Create(builder => builder.AddProfile())`\. See +[ComposeAttribute](Compono.TUnit.ComposeAttribute.md 'Compono\.TUnit\.ComposeAttribute') for the full binding algorithm\. + +```csharp +public sealed class ComposeAttribute : Compono.TUnit.ComposeAttribute + where TProfile : Compono.ICompositionProfile, new() +``` +#### Type parameters + + + +`TProfile` + +The profile to apply\. + +Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object') → [System\.Attribute](https://learn.microsoft.com/en-us/dotnet/api/system.attribute 'System\.Attribute') → `TUnit.Core.AsyncUntypedDataSourceGeneratorAttribute` → `TUnit.Core.UntypedDataSourceGeneratorAttribute` → [ComposeAttribute](Compono.TUnit.ComposeAttribute.md 'Compono\.TUnit\.ComposeAttribute') → ComposeAttribute\ + +### Remarks +A profile type that doesn't implement [ICompositionProfile](../Compono/Compono.ICompositionProfile.md 'Compono\.ICompositionProfile') or lacks a public +parameterless constructor is a compile error at the `[Compose]` use site +\(C\# enforces generic\-attribute constraints there like any other generic type\) \- there is no +runtime "invalid profile type" diagnostic to design\. Mirrors +`Compono.XunitV3.ComposeAttribute{TProfile}` exactly\. + +| Constructors | | +| :--- | :--- | +| [ComposeAttribute\(object\[\]\)](Compono.TUnit.ComposeAttribute_TProfile_.ComposeAttribute(object[]).md 'Compono\.TUnit\.ComposeAttribute\\.ComposeAttribute\(object\[\]\)') | Creates a [ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\')\. | diff --git a/docs/reference/api/Compono.TUnit/Compono.TUnit.md b/docs/reference/api/Compono.TUnit/Compono.TUnit.md index 256c0ec..a8ddc62 100644 --- a/docs/reference/api/Compono.TUnit/Compono.TUnit.md +++ b/docs/reference/api/Compono.TUnit/Compono.TUnit.md @@ -5,4 +5,6 @@ | Classes | | | :--- | :--- | | [ComposeAttribute](Compono.TUnit.ComposeAttribute.md 'Compono\.TUnit\.ComposeAttribute') | Composes a TUnit test method's parameters through Compono \- the default \(no explicit profile\) entry point\. Every parameter not supplied inline is composed; a parameter targeted by a supplied inline value takes that value instead, taking precedence over composition\. See `docs/adr/0040-compono-tunit-package-design.md` for the full binding algorithm, seed policy, and diagnostics \- adapted from `Compono.XunitV3.ComposeAttribute`, not a byte\-for\-byte port \(TUnit hands a data source `TUnit.Core.DataGeneratorMetadata`, not a `MethodInfo`\)\. | +| [ComposeAttribute<TProfile,TConfig>](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\') | Composes a TUnit test method's parameters through Compono, applying a profile built from \profile configuration arguments\ known at this attribute's call site \- a distinct concept from this attribute family's ordinary inline values \([ComposeAttribute\(object\[\]\)](Compono.TUnit.ComposeAttribute.ComposeAttribute(object[]).md 'Compono\.TUnit\.ComposeAttribute\.ComposeAttribute\(object\[\]\)')\), which bind to the test method's own parameters instead\. This constructor never binds to the test method's parameters at all; every one of them is composed in full\. [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig') is constructed positionally from this attribute's own constructor arguments, then [TProfile](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile') is constructed from that [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\\.TConfig') instance and applied via [AddProfile\(ICompositionProfile\)](../Compono/Compono.CompositionBuilder.AddProfile.md#Compono.CompositionBuilder.AddProfile(Compono.ICompositionProfile) 'Compono\.CompositionBuilder\.AddProfile\(Compono\.ICompositionProfile\)') \- equivalent to `Composer.Create(builder => builder.AddProfile(new TProfile(new TConfig(...))))`\. See `docs/adr/0036-parameterized-composition-profile-selection.md` for the full design, including why this exists as a separate attribute rather than overloading [ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\')'s own inline\-value constructor argument\. Mirrors `Compono.XunitV3.ComposeAttribute{TProfile, TConfig}` exactly\. | +| [ComposeAttribute<TProfile>](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\') | Composes a TUnit test method's parameters through Compono, with [TProfile](Compono.TUnit.ComposeAttribute_TProfile_.md#Compono.TUnit.ComposeAttribute_TProfile_.TProfile 'Compono\.TUnit\.ComposeAttribute\\.TProfile') applied to the underlying [Composer](../Compono/Compono.Composer.md 'Compono\.Composer') \- equivalent to `Composer.Create(builder => builder.AddProfile())`\. See [ComposeAttribute](Compono.TUnit.ComposeAttribute.md 'Compono\.TUnit\.ComposeAttribute') for the full binding algorithm\. | | [SharedAttribute](Compono.TUnit.SharedAttribute.md 'Compono\.TUnit\.SharedAttribute') | Marks a `[Compose]`\-attributed test method parameter as shared: its composed \(or inline\-supplied\) value is stored in the row's [CompositionRow](../Compono/Compono.CompositionRow.md 'Compono\.CompositionRow') scope, so any other composed parameter or nested generated dependency that structurally requests the same type in the same row reuses this exact value instead of composing its own independent one\. | diff --git a/skills/compono/references/tunit.md b/skills/compono/references/tunit.md index e2fa750..9c4d41a 100644 --- a/skills/compono/references/tunit.md +++ b/skills/compono/references/tunit.md @@ -4,8 +4,9 @@ Only relevant if the project references `Compono.TUnit`. Requires real TUnit (`TUnit`/`TUnit.Core` + Microsoft Testing Platform runner). Depends on `Compono` (the source generator flows through transitively). -This is PLAN-0040's first, method-parameter-only slice (Phase 0) — see -ADR-0040 for the full design and which forms ship in which phase. +PLAN-0040 Phase 0/1 have shipped: `[Compose]`, `[Compose]`, and +`[Compose]`, method-parameter-only — see ADR-0040 for +the full design. ## `[Compose]` @@ -42,14 +43,75 @@ public async Task ReproducesTheSameComposedValues(Order order) { } - Composition happens at data-generation time, not a separate discovery pass. -## `[Compose]` / `[Compose]` +## `[Compose]` -Not part of this first slice — see PLAN-0040's later phases and -`references/xunit-v3.md` for the shape these will eventually mirror once -they land in `Compono.TUnit` too. Until then, `Compono.TUnit` has no -profile-application mechanism at all — a `[Compose]`-composed type that -needs a substitute, Bogus-generated data, or a custom registration can't -get one through this package yet. +```csharp +[Test] +[Compose] +public async Task Creates_service( + [Shared] IOrderRepository repository, + OrderService service, + CreateOrder command) +{ +} +``` + +Same behavior as `[Compose]`, but applies `TProfile.Configure` to the +row's builder first — this is how a test picks up +`UseNSubstitute()`/`UseBogus()`/registrations for that specific test. + +## `[Compose]` + +```csharp +public enum RepositoryKind { Player, Game } + +public sealed record RepositoryConfig(RepositoryKind Repository); + +public sealed class RepositoryProfile : ICompositionProfile +{ + public RepositoryProfile(RepositoryConfig config) => Config = config; + public RepositoryConfig Config { get; } + public void Configure(CompositionBuilder builder) => + builder.Register(_ => RepositoryFactory.Create(Config.Repository)); +} + +[Test] +[Compose(RepositoryKind.Player)] +public async Task Handles_PlayerRepository(IRepository repository) { } +``` + +Use this when a profile needs a value only known at **this specific +test's call site** - not a fixed, default-constructed profile the way +`[Compose]` always is. `TConfig`'s constructor arguments here +(**profile configuration arguments**) are a completely different binding +target from this file's inline values above - they never bind to the +test method's own parameters, all of which are still composed in full. + +- `TConfig` must have exactly one public constructor; `TProfile` must have + exactly one public constructor accepting exactly one `TConfig`-typed + parameter. Either shape being wrong is a clear `CompositionException` + raised during composer/profile initialization (`ApplyProfile`, inside + the base class's cached `Lazy`) - before `BindingPlan` is + ever built, not a compile error (`[Compose]`'s `new()` + constraint doesn't carry over to this form - see + `docs/adr/0036-parameterized-composition-profile-selection.md`). +- **Use the strongest attribute-legal type for each argument** - an + `enum` for a finite choice, `typeof(...)` for a CLR type, `bool`/numeric + where that's already the real meaning. `params object?[]` is a binding + mechanism C# attribute rules force, not a reason to design `TConfig` + around magic strings. +- **This is not the same problem as name-based value selection.** A value + that varies by which parameter/member is *asking* (not by test call + site) is a `CompositionProviderRequest.Name`-matching custom + `ICompositionValueProvider` question - see + `registrations-profiles-and-scopes.md`. Don't reach for + `[Compose]` for that case, and don't reach for a + custom provider for this one. +- **Don't reach for this form by default.** If a fixed, default-constructed + profile already covers it, the plain `[Compose]` form is + enough - reserve this one for a value that's genuinely different per + call site and needs to reach configuration logic running *inside* the + profile. ## Disposal — read before assuming automatic cleanup @@ -66,17 +128,27 @@ disposal story (no automatic disposal at all, PR #24) carries over unchanged; the two packages differ here because TUnit's own execution model differs from xUnit v3's. -## Stacking Compose-family attributes: undefined, not rejected - -Unlike `Compono.XunitV3` (which throws a clear `CompositionException` for -this shape), `Compono.TUnit`'s `BindingPlan.Build` does not currently -detect more than one Compose-family attribute stacked on the same method - -`MethodMetadata` doesn't expose the method's own attribute list the way a -raw `MethodInfo` does, and this check hasn't been added yet (a known v1 -gap, tracked in PLAN-0040's Phase 1 checklist ("Stacked Compose-family attribute validation")). Don't stack Compose-family -attributes on one TUnit test method - the result is undefined, not a -documented failure mode; if you see it in review, flag it the same way -you'd flag any other unsupported shape. +## Hard constraint: one Compose-family attribute per method + +`[Compose]` and `[Compose]` are both `ComposeAttribute` +subclasses. Two **different** Compose-family attributes on one method +(e.g. `[Compose]` + `[Compose]`) *compile* but throw +`CompositionException` at data-generation time, not compile time — +`BindingPlan.ValidateSignature` resolves the method's own `MethodInfo` +(via a parameter's `ReflectionInfo.Member`, or - for a zero-parameter +method, which has no parameter to read that from - an arity-aware +`GetMethods()` filter matched on name, zero declared parameters, *and* +generic arity together, not a plain `GetMethod(name, Type.EmptyTypes)` +call, which would throw `AmbiguousMatchException` for a class declaring +both a zero-parameter `Run()` and a zero-parameter-but-generic `Run()`) +and counts `ComposeAttribute`-derived attributes on it. The identical +attribute type twice on one method **is** a compiler error +(`AllowMultiple=false`). + +**There is no equivalent of stacking multiple data-source attributes on +one method.** If a test needs several independent inline+composed +combinations, split into separate `[Test]`/`[Arguments]` methods — don't +try to layer multiple Compose-family attributes to get that effect. ## No fixture object @@ -93,3 +165,7 @@ class. - `test/Compono.TUnit.SampleTests/DisposalTests.cs` — the root-disposed vs. nested-not-disposed proof, using a plain purpose-built `IDisposable` type, not a mocking-library substitute. +- `test/Compono.TUnit.SampleTests/NSubstituteTests.cs` — + `[Compose] async Task Saves_order([Shared] + IOrderRepository repository, CreateOrderHandler handler, PlaceOrder + command)`. diff --git a/src/Compono.TUnit/Binding/BindingPlan.cs b/src/Compono.TUnit/Binding/BindingPlan.cs index 770d7ca..7070457 100644 --- a/src/Compono.TUnit/Binding/BindingPlan.cs +++ b/src/Compono.TUnit/Binding/BindingPlan.cs @@ -1,3 +1,4 @@ +using System.Reflection; using global::TUnit.Core; namespace Compono.TUnit.Binding; @@ -82,15 +83,27 @@ internal static string MethodDisplayName(MethodMetadata testInformation) => // Mirrors Compono.XunitV3's own "Async and Unsupported Shapes" validation, adapted to what // ParameterMetadata already computes (IsParams) versus what still needs one reflection call per - // parameter (ByRef - ParameterMetadata has no ready-made equivalent). Deliberately does not - // detect more than one Compose-family attribute stacked on the same method (Compono.XunitV3's - // own such check) - DataGeneratorMetadata/MethodMetadata don't expose the method's own attribute - // list at generation time the way a raw MethodInfo would; left as a known v1 scope reduction, not - // a silently dropped requirement. + // parameter (ByRef - ParameterMetadata has no ready-made equivalent). private static string? ValidateSignature(MethodMetadata testInformation, ParameterMetadata[] parameters) { var methodDisplayName = MethodDisplayName(testInformation); + // [AttributeUsage(AllowMultiple = false)] is enforced per exact attribute type by the + // compiler, not across a base/derived family - [Compose], [Compose], and + // [Compose] (or two differently-closed forms of either generic one) are + // distinct types that each individually satisfy their own AllowMultiple = false, so nothing + // stops stacking more than one Compose-family attribute on the same method without this + // explicit check. Mirrors Compono.XunitV3.Binding.BindingPlan's identical check, adapted to + // what MethodMetadata/ParameterMetadata expose: a parameter's ReflectionInfo.Member is the + // declaring MethodInfo whenever the method has at least one parameter; a zero-parameter + // method needs its own lookup instead (see ResolveMethodInfo below - filtered by name, + // parameter count, and generic arity together, not parameter count alone). + var method = ResolveMethodInfo(testInformation, parameters); + var composeAttributeCount = method?.GetCustomAttributes(inherit: false).Count() ?? 0; + + if (composeAttributeCount > 1) + return $"More than one [Compose]/[Compose]/[Compose] attribute on '{methodDisplayName}' - only one Compose-family attribute per test method is allowed."; + if (testInformation.GenericTypeCount > 0) return $"Compono.TUnit does not support generic test methods ('{methodDisplayName}')."; @@ -126,4 +139,28 @@ internal static string MethodDisplayName(MethodMetadata testInformation) => return null; } + + // A parameter's own ReflectionInfo.Member is always the declaring MethodInfo - the cheapest + // possible lookup, and correct even for an overloaded method name, since it's the exact + // MethodInfo TUnit itself resolved this parameter from. A zero-parameter method has no + // parameter to read that from, so falls back to a direct lookup instead - but + // Type.GetMethod(name, Type.EmptyTypes) matches by parameter *types* only, not generic arity, so + // a class declaring both a zero-parameter Run() and a zero-parameter-but-generic Run() throws + // AmbiguousMatchException instead of returning either - before the generic-method check above + // even gets a chance to produce its own clear CompositionException (Codex review). Filtering + // GetMethods() by both zero declared parameters and testInformation.GenericTypeCount (this + // specific test's own arity, whether zero or not) disambiguates that case the same way the + // compiler already did to produce this exact MethodMetadata. + private static MethodInfo? ResolveMethodInfo(MethodMetadata testInformation, ParameterMetadata[] parameters) + { + if (parameters.Length > 0) + return parameters[0].ReflectionInfo.Member as MethodInfo; + + return testInformation.Class.Type + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .FirstOrDefault(candidate => + candidate.Name == testInformation.Name && + candidate.GetParameters().Length == 0 && + (candidate.IsGenericMethodDefinition ? candidate.GetGenericArguments().Length : 0) == testInformation.GenericTypeCount); + } } diff --git a/src/Compono.TUnit/Binding/ConfigProfileBinder.cs b/src/Compono.TUnit/Binding/ConfigProfileBinder.cs new file mode 100644 index 0000000..c6da097 --- /dev/null +++ b/src/Compono.TUnit/Binding/ConfigProfileBinder.cs @@ -0,0 +1,191 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.ExceptionServices; + +namespace Compono.TUnit.Binding; + +/// +/// Resolves and invokes the single constructors uses +/// to build a TConfig from profile configuration arguments and a TProfile from that +/// TConfig - see +/// docs/adr/0036-parameterized-composition-profile-selection.md's "Constructor contracts" +/// section. Deliberately narrow and deterministic: no "best constructor match" heuristic exists +/// anywhere here - an unsupported or ambiguous constructor shape is always a clear, named +/// , never a guessed resolution. Duplicated from +/// Compono.XunitV3.Binding.ConfigProfileBinder, per this package's own binding-logic +/// duplication decision (see 's remarks). +/// +/// +/// Every method here is reflection - and +/// , not the non-generic - +/// backed dispatch uses. That shape exists there to close per-parameter +/// dispatch known only at runtime, once per parameter, so the per-row path never reflects again. +/// TConfig/TProfile need no equivalent: they're already compile-time-closed generic +/// arguments on itself, and this binder's methods +/// are only ever called from - itself +/// only ever invoked once per attribute instance, from inside the base 's +/// existing Lazy<Composer>-backed caching. That existing caching is what bounds this +/// reflection to once per attribute instance, never the repeated per-row data-source path - no +/// separate caching layer is needed here. See ADR-0041 Amendment 1/PLAN-0040 Phase 1's Native AOT gate +/// task for this reflection's own AOT-safety verification. +/// +internal static class ConfigProfileBinder +{ + /// + /// Binds positionally to 's single + /// public constructor and invokes it - the same count/nullability/assignability validation + /// 's own inline-value binding uses, retargeted at a constructor's + /// parameters instead of a test method's. + /// + /// + /// does not have exactly one public constructor; the supplied + /// argument count doesn't match that constructor's parameter count; a supplied argument is + /// for a non-nullable parameter; or a supplied argument's type isn't + /// assignable to its parameter's type. + /// + public static object BindConfig( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type configType, + IReadOnlyList configArguments) + { + var constructor = ResolveSingleConstructor(configType); + var parameters = constructor.GetParameters(); + + if (configArguments.Count != parameters.Length) + { + throw new CompositionException( + $"'{configType}' requires {parameters.Length} profile configuration argument(s), but {configArguments.Count} were supplied."); + } + + var arguments = new object?[parameters.Length]; + var nullabilityContext = new NullabilityInfoContext(); + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var value = configArguments[i]; + var nullability = IsNullable(nullabilityContext, parameter) ? Nullability.Nullable : Nullability.NotNullable; + + switch (PositionalArgumentBinder.Validate(parameter.ParameterType, nullability, value)) + { + case PositionalArgumentValidation.NullNotAllowed: + throw new CompositionException( + $"Profile configuration argument for parameter '{parameter.Name}' of '{configType}' is null, but the parameter is not nullable."); + + case PositionalArgumentValidation.TypeMismatch: + throw new CompositionException( + $"Profile configuration argument for parameter '{parameter.Name}' of '{configType}' has type '{value!.GetType()}', which is not assignable to '{parameter.ParameterType}'."); + } + + arguments[i] = value; + } + + return Invoke(constructor, arguments); + } + + /// + /// Constructs a from , via + /// 's single public constructor accepting exactly one + /// -typed parameter. + /// + /// + /// does not have exactly one public constructor accepting exactly + /// one -typed parameter. + /// + public static TProfile BuildProfile<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProfile, TConfig>(object config) + where TProfile : ICompositionProfile + { + var constructor = ResolveSingleProfileConstructor(typeof(TProfile), typeof(TConfig)); + + return (TProfile)Invoke(constructor, [config]); + } + + // ConstructorInfo.Invoke wraps any exception the constructor body itself throws (e.g. a + // CompositionException from custom TConfig/TProfile validation logic) in a + // TargetInvocationException - without unwrapping it here, ApplyProfile's own + // catch (CompositionException) could never observe it, and the caller would see a generic + // reflection failure with no seed-reporting instead of the constructor's own actionable + // exception. ExceptionDispatchInfo.Capture(...).Throw() re-throws the inner exception with its + // original stack trace preserved, rather than a bare `throw exception.InnerException` (which + // would reset it) or catching only CompositionException specifically (which would still let a + // non-CompositionException constructor-thrown exception stay wrongly wrapped). + private static object Invoke(ConstructorInfo constructor, object?[] arguments) + { + try + { + return constructor.Invoke(arguments); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; // Unreachable - Throw() always throws; satisfies every code path returning a value. + } + } + + private static ConstructorInfo ResolveSingleConstructor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) + { + // An abstract type can still declare a public constructor (invoked only by a derived type's + // own constructor chain) - GetConstructors would find it and this method would otherwise + // hand it back as "the one constructor," but ConstructorInfo.Invoke on it throws + // MemberAccessException ("Cannot create an abstract class"), not the documented + // CompositionException - reject explicitly, before the constructor count check, so an + // abstract TConfig fails with the same named diagnostic shape as every other unsupported + // shape here. + if (type.IsAbstract) + { + throw new CompositionException( + $"'{type}' is abstract and cannot be used as profile configuration - it must be a concrete, constructible type."); + } + + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Length != 1) + { + throw new CompositionException( + $"'{type}' must have exactly one public constructor to be used as profile configuration, but has {constructors.Length}."); + } + + return constructors[0]; + } + + private static ConstructorInfo ResolveSingleProfileConstructor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type profileType, + Type configType) + { + // Same reasoning as ResolveSingleConstructor's abstract check above - an abstract TProfile + // with a matching public constructor would otherwise reach ConstructorInfo.Invoke and throw + // MemberAccessException instead of the documented CompositionException. + if (profileType.IsAbstract) + { + throw new CompositionException( + $"'{profileType}' is abstract and cannot be used as a profile - it must be a concrete, constructible type."); + } + + var matching = profileType.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Where(constructor => + { + var parameters = constructor.GetParameters(); + return parameters.Length == 1 && parameters[0].ParameterType == configType; + }) + .ToArray(); + + if (matching.Length != 1) + { + throw new CompositionException( + $"'{profileType}' must have exactly one public constructor accepting a single '{configType}' parameter, but has {matching.Length}."); + } + + return matching[0]; + } + + private static bool IsNullable(NullabilityInfoContext nullabilityContext, ParameterInfo parameter) + { + if (Nullable.GetUnderlyingType(parameter.ParameterType) is not null) + return true; + + if (parameter.ParameterType.IsValueType) + return false; + + return nullabilityContext.Create(parameter).ReadState == NullabilityState.Nullable; + } +} diff --git a/src/Compono.TUnit/Compono.TUnit.csproj b/src/Compono.TUnit/Compono.TUnit.csproj index 61fc798..fcbc403 100644 --- a/src/Compono.TUnit/Compono.TUnit.csproj +++ b/src/Compono.TUnit/Compono.TUnit.csproj @@ -6,7 +6,7 @@ enable net8.0;net9.0;net10.0;net11.0 Compono — TUnit Integration - TUnit integration for Compono - a [Compose] data source attribute and [Shared] parameter sharing. Profile variants ([Compose<TProfile>]/[Compose<TProfile, TConfig>]) ship in a later phase - see PLAN-0040. + TUnit integration for Compono - a [Compose] data source attribute (with [Compose<TProfile>]/[Compose<TProfile, TConfig>] profile variants) and [Shared] parameter sharing. diff --git a/src/Compono.TUnit/ComposeAttribute{TProfile,TConfig}.cs b/src/Compono.TUnit/ComposeAttribute{TProfile,TConfig}.cs new file mode 100644 index 0000000..3b53177 --- /dev/null +++ b/src/Compono.TUnit/ComposeAttribute{TProfile,TConfig}.cs @@ -0,0 +1,117 @@ +using System.Diagnostics.CodeAnalysis; +using Compono.TUnit.Binding; + +namespace Compono.TUnit; + +/// +/// Composes a TUnit test method's parameters through Compono, applying a profile built from +/// profile configuration arguments known at this attribute's call site - a distinct concept +/// from this attribute family's ordinary inline values (), +/// which bind to the test method's own parameters instead. This constructor never binds to the test +/// method's parameters at all; every one of them is composed in full. is +/// constructed positionally from this attribute's own constructor arguments, then +/// is constructed from that instance and +/// applied via - equivalent to +/// Composer.Create(builder => builder.AddProfile(new TProfile(new TConfig(...)))). See +/// docs/adr/0036-parameterized-composition-profile-selection.md for the full design, including +/// why this exists as a separate attribute rather than overloading +/// 's own inline-value constructor argument. Mirrors +/// Compono.XunitV3.ComposeAttribute{TProfile, TConfig} exactly. +/// +/// +/// The profile to construct and apply. Must have exactly one public constructor accepting exactly one +/// -typed parameter - no new() constraint, unlike +/// , since this form is never default-constructed. +/// +/// +/// The type this attribute's constructor arguments bind to, positionally, against its own single +/// public constructor. Prefer strongly-typed, attribute-legal values for its constructor parameters - +/// an for a finite choice, via typeof(...) for a CLR +/// type, a plain /numeric/string value where that already carries the real +/// meaning - over loosely-typed primitives standing in for something more specific. +/// params object?[] is a binding mechanism forced by C#'s attribute-argument-must-be-a- +/// compile-time-constant rule, not a license to design around magic +/// strings. +/// +/// +/// Unlike 's compile-time-enforced new() constraint, an +/// unsupported / constructor shape (not +/// exactly one public constructor on ; no exactly-one- +/// -parameter public constructor on ) is a +/// deterministic runtime , not a compile error - there is no C# +/// generic constraint that expresses "has a constructor accepting exactly this type." Both constructor +/// lookups, and the actual construction, are reflection () - bounded +/// and cached to once per attribute instance by this attribute family's existing +/// -backed caching ( is only ever +/// invoked from inside that lazy initializer), never on the repeated per-row data-source path. +/// and both carry +/// () +/// - required, not decorative: a real Native AOT publish-and-run proof (ADR-0041 Amendment 1) showed +/// the trimmer strips a closed generic argument's public constructors by default, since nothing in an +/// unannotated Type.GetConstructors() call site tells it they're reachable - ConfigProfileBinder +/// failed at runtime with "has 0" public constructors on a type that plainly has one, until these +/// annotations were added at every generic parameter/Type-typed parameter along the call chain. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class ComposeAttribute< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProfile, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TConfig> : ComposeAttribute + where TProfile : ICompositionProfile +{ + private readonly object?[] _configArguments; + + /// + /// Creates a . + /// + /// + /// Profile configuration arguments, bound positionally to 's single + /// public constructor - an entirely separate binding target from this attribute family's ordinary + /// inline values; every test method parameter is composed in full regardless of what's supplied + /// here. See the type-level remarks for why each argument should use the strongest attribute-legal + /// type available rather than a bare string. + /// + public ComposeAttribute(params object?[] configArguments) : base() + { + _configArguments = NormalizeParamsArguments(configArguments); + } + + internal override void ApplyProfile(CompositionBuilder builder) + { + // A negative configured seed must be rejected before any config/profile binding is + // attempted - otherwise Seed = -1 combined with an invalid TConfig/TProfile shape would + // report the binder failure below with "Seed: -1" embedded instead of the documented + // negative-seed diagnostic the base class's own ComposeRow enforces. SeedAsNullable is + // exactly what CompositionRow.Seed would resolve to if non-negative (Composer.CreateRow's + // unseeded fallback only ever generates a non-negative value), so checking it here - before + // a CompositionRow even exists - gives the identical guarantee ComposeRow's own row.Seed < 0 + // check gives, just earlier. + if (SeedAsNullable is { } configuredSeed && configuredSeed < 0) + { + throw new CompositionException(AppendSeed( + $"Compono.TUnit requires a non-negative seed, but the configured seed was {configuredSeed}.", + configuredSeed)); + } + + try + { + var config = ConfigProfileBinder.BindConfig(typeof(TConfig), _configArguments); + var profile = ConfigProfileBinder.BuildProfile(config); + + builder.AddProfile(profile); + } + catch (CompositionException exception) + { + // ApplyProfile runs while the base class's Lazy is still being built - before + // ComposeRow ever calls Composer.CreateRow, so no CompositionRow/row.Seed exists yet at + // this point. Report the seed this attribute is actually configured with (SeedAsNullable, + // already proven non-negative by the check above) - the same seed row.Seed would resolve + // to once composition succeeds - or a freshly generated one otherwise. Reproducibility + // isn't actually the point for this specific failure category (a constructor-shape/ + // argument mismatch fails identically regardless of seed); this is purely the "every + // Compono.TUnit-owned failure ends with Seed: {value}" convention, applied consistently + // rather than as an exception for this one binding path. + var seed = SeedAsNullable ?? Random.Shared.Next(0, int.MaxValue); + throw CompositionException.WithSeedInMessage(exception, seed); + } + } +} diff --git a/src/Compono.TUnit/ComposeAttribute{TProfile}.cs b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs new file mode 100644 index 0000000..7a072d0 --- /dev/null +++ b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs @@ -0,0 +1,63 @@ +namespace Compono.TUnit; + +/// +/// Composes a TUnit test method's parameters through Compono, with +/// applied to the underlying - equivalent to +/// Composer.Create(builder => builder.AddProfile<TProfile>()). See +/// for the full binding algorithm. +/// +/// The profile to apply. +/// +/// A profile type that doesn't implement or lacks a public +/// parameterless constructor is a compile error at the [Compose<TProfile>] use site +/// (C# enforces generic-attribute constraints there like any other generic type) - there is no +/// runtime "invalid profile type" diagnostic to design. Mirrors +/// Compono.XunitV3.ComposeAttribute{TProfile} exactly. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class ComposeAttribute : ComposeAttribute + where TProfile : ICompositionProfile, new() +{ + /// + /// Creates a . + /// + /// + /// Values supplied positionally, left-to-right from the test method's first parameter - see + /// . + /// + public ComposeAttribute(params object?[] inlineValues) : base(inlineValues) + { + } + + internal override void ApplyProfile(CompositionBuilder builder) + { + // A negative configured seed must be rejected before any profile work is attempted - + // otherwise Seed = -1 combined with a throwing TProfile.Configure would report the profile + // failure below with "Seed: -1" embedded instead of the documented negative-seed diagnostic + // the base class's own ComposeRow enforces. Matches ComposeAttribute's + // identical early check (Codex review). + if (SeedAsNullable is { } configuredSeed && configuredSeed < 0) + { + throw new CompositionException(AppendSeed( + $"Compono.TUnit requires a non-negative seed, but the configured seed was {configuredSeed}.", + configuredSeed)); + } + + try + { + builder.AddProfile(); + } + catch (CompositionException exception) + { + // ApplyProfile runs while the base class's Lazy is still being built - before + // ComposeRow ever calls Composer.CreateRow, so no CompositionRow/row.Seed exists yet at + // this point. TProfile.Configure throwing here (e.g. a bad registration) must still end + // with the "Seed: {value}" convention every Compono.TUnit-owned pre-composition failure + // uses, matching ComposeAttribute's identical wrapping for its own + // ApplyProfile failures - otherwise even a configured Seed goes unreported for this + // profile form specifically (Codex review). + var seed = SeedAsNullable ?? Random.Shared.Next(0, int.MaxValue); + throw CompositionException.WithSeedInMessage(exception, seed); + } + } +} diff --git a/test/Compono.TUnit.AotSmokeTest/Program.cs b/test/Compono.TUnit.AotSmokeTest/Program.cs index 394861f..d0e655f 100644 --- a/test/Compono.TUnit.AotSmokeTest/Program.cs +++ b/test/Compono.TUnit.AotSmokeTest/Program.cs @@ -14,6 +14,21 @@ internal sealed class Widget public string Name { get; } } +// PLAN-0040 Phase 1's own Native AOT gate (ADR-0041 Amendment 1): ConfigProfileBinder's +// ConstructorInfo.Invoke-based TConfig/TProfile construction needs the same real publish-and-run +// proof RowInvokerRegistry dispatch already got in Phase 0 - "likely AOT-safe because it's a +// non-generic, already-known Type" isn't good enough on its own. +internal sealed record ProfileConfig(int Seed); + +internal sealed class ConfiguredProfile : ICompositionProfile +{ + private readonly ProfileConfig _config; + + public ConfiguredProfile(ProfileConfig config) => _config = config; + + public void Configure(CompositionBuilder builder) => builder.WithSeed(_config.Seed); +} + internal static class SmokeTestMethods { // The real target of this whole harness: a real Compono.TUnit.ComposeAttribute-attributed method @@ -31,6 +46,14 @@ internal static class SmokeTestMethods public static void Handle(Widget widget, string leaf) { } + + // Exercises ComposeAttribute.ApplyProfile -> ConfigProfileBinder.BindConfig/ + // BuildProfile, both ConstructorInfo.Invoke-based - the Phase 1 AOT gate this harness exists to + // prove. + [Compose(12345)] + public static void HandleWithConfiguredProfile(Widget widget, string leaf) + { + } } internal static class Program @@ -39,23 +62,16 @@ private static async Task Main() { try { - var method = typeof(SmokeTestMethods).GetMethod(nameof(SmokeTestMethods.Handle))!; - var attribute = new ComposeAttribute(); - var metadata = CreateDataGeneratorMetadata(method); - - var factories = new List>>(); - await foreach (var factory in attribute.GetDataRowsAsync(metadata)) - factories.Add(factory); + await RunRow( + typeof(SmokeTestMethods).GetMethod(nameof(SmokeTestMethods.Handle))!, + new ComposeAttribute(), + "Compono.TUnit.ComposeAttribute"); - if (factories.Count != 1) - throw new InvalidOperationException($"Expected exactly one data row, got {factories.Count}."); + await RunRow( + typeof(SmokeTestMethods).GetMethod(nameof(SmokeTestMethods.HandleWithConfiguredProfile))!, + new ComposeAttribute(12345), + "Compono.TUnit.ComposeAttribute (ConfigProfileBinder)"); - var data = await factories[0](); - - if (data is not [Widget { Name.Length: > 0 } widget, string { Length: > 0 } leaf]) - throw new InvalidOperationException($"Unexpected composed row: {(data is null ? "null" : string.Join(", ", data))}"); - - Console.WriteLine($"PASS: Compono.TUnit.ComposeAttribute dispatch survived Native AOT - Widget.Name='{widget.Name}', leaf='{leaf}'."); return 0; } catch (Exception ex) @@ -65,6 +81,25 @@ private static async Task Main() } } + private static async Task RunRow(MethodInfo method, ComposeAttribute attribute, string label) + { + var metadata = CreateDataGeneratorMetadata(method); + + var factories = new List>>(); + await foreach (var factory in attribute.GetDataRowsAsync(metadata)) + factories.Add(factory); + + if (factories.Count != 1) + throw new InvalidOperationException($"Expected exactly one data row, got {factories.Count}."); + + var data = await factories[0](); + + if (data is not [Widget { Name.Length: > 0 } widget, string { Length: > 0 } leaf]) + throw new InvalidOperationException($"Unexpected composed row: {(data is null ? "null" : string.Join(", ", data))}"); + + Console.WriteLine($"PASS: {label} dispatch survived Native AOT - Widget.Name='{widget.Name}', leaf='{leaf}'."); + } + // Hand-builds a real DataGeneratorMetadata/MethodMetadata from a real MethodInfo via reflection - // this harness has no TUnit source-generator wiring (a plain PackageReference doesn't produce // TUnit's own generated MethodMetadata), so it needs the same reflection-based construction diff --git a/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj b/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj index 3cfd5cc..b4a8028 100644 --- a/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj +++ b/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj @@ -42,17 +42,27 @@ + + + + - + diff --git a/test/Compono.TUnit.SampleTests/NSubstituteTests.cs b/test/Compono.TUnit.SampleTests/NSubstituteTests.cs new file mode 100644 index 0000000..275a9f6 --- /dev/null +++ b/test/Compono.TUnit.SampleTests/NSubstituteTests.cs @@ -0,0 +1,51 @@ +using NSubstitute; + +namespace Compono.TUnit.SampleTests; + +// PLAN-0040 Phase 1's own Goal-section scenario, run for real: composing through the actual +// packaged Compono.NSubstitute -> Compono.TUnit -> Compono dependency chain under a real TUnit +// runner, not just Compono.TUnit.Tests' own direct GetDataRowsAsync calls. Mirrors +// Compono.XunitV3.SampleTests/NSubstituteTests.cs exactly. +public interface IOrderRepository +{ + Task SaveAsync(Order order, CancellationToken cancellationToken); +} + +public sealed record Order; + +public sealed record PlaceOrder(string CustomerName, int Quantity); + +public sealed class CreateOrderHandler +{ + public CreateOrderHandler(IOrderRepository repository) + { + Repository = repository; + } + + public IOrderRepository Repository { get; } + + public Task Handle(PlaceOrder command) => Repository.SaveAsync(new Order(), CancellationToken.None); +} + +// Applies UseNSubstitute() to this row's own CompositionBuilder, exactly like an application's +// Program.cs would - reached only through NSubstituteTests.Saves_order's own [Compose] +// method parameter. +public sealed class NSubstituteTestProfile : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) => builder.UseNSubstitute(); +} + +public sealed class NSubstituteTests +{ + // This plan's own Goal-section example, run for real: repository is a real NSubstitute + // substitute, reused as the exact same instance inside handler's own composed IOrderRepository + // constructor parameter, with no manual Substitute.For() call anywhere in this test. + [Test] + [Compose] + public async Task Saves_order([Shared] IOrderRepository repository, CreateOrderHandler handler, PlaceOrder command) + { + await handler.Handle(command); + + await repository.Received(1).SaveAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/test/Compono.TUnit.SampleTests/pack-to-local-feed.sh b/test/Compono.TUnit.SampleTests/pack-to-local-feed.sh index 1f37499..fb5b689 100755 --- a/test/Compono.TUnit.SampleTests/pack-to-local-feed.sh +++ b/test/Compono.TUnit.SampleTests/pack-to-local-feed.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Packs Compono and Compono.TUnit into the local NuGet feed this project restores against -# (test/Compono.TUnit.SampleTests/nuget.config), serialized behind a cross-process lock, and clears -# this restore's own isolated packages path before every pack. Mirrors +# Packs Compono, Compono.TUnit, and Compono.NSubstitute into the local NuGet feed this project +# restores against (test/Compono.TUnit.SampleTests/nuget.config), serialized behind a cross-process +# lock, and clears this restore's own isolated packages path before every pack. Mirrors # test/Compono.XunitV3.SampleTests/pack-to-local-feed.sh exactly - see that script's own comment for # the full reasoning behind the lock (concurrent nested `dotnet test` invocations racing on the same # .local-nuget-feed/ and src/Compono*/bin/obj output) and the isolated restore-packages-path clear @@ -11,9 +11,10 @@ set -euo pipefail compono_csproj="$1" tunit_csproj="$2" -feed_dir="$3" -configuration="$4" -restore_packages_path="$5" +nsubstitute_csproj="$3" +feed_dir="$4" +configuration="$5" +restore_packages_path="$6" lock_dir="$feed_dir/.pack.lock" @@ -37,3 +38,4 @@ rm -rf "$restore_packages_path" dotnet pack "$compono_csproj" -c "$configuration" -o "$feed_dir" -p:Version=1.0.0 --nologo dotnet pack "$tunit_csproj" -c "$configuration" -o "$feed_dir" -p:Version=1.0.0 --nologo +dotnet pack "$nsubstitute_csproj" -c "$configuration" -o "$feed_dir" -p:Version=1.0.0 --nologo diff --git a/test/Compono.TUnit.Tests/BindingPlanTests.cs b/test/Compono.TUnit.Tests/BindingPlanTests.cs index a1468c6..5c8ec16 100644 --- a/test/Compono.TUnit.Tests/BindingPlanTests.cs +++ b/test/Compono.TUnit.Tests/BindingPlanTests.cs @@ -124,6 +124,71 @@ public async Task Build_CapturesEachParametersNullability() await Assert.That(plan.Parameters[3].Descriptor.Nullability).IsEqualTo(Nullability.NotNullable); } + [Test] + public async Task Build_ReportsASignatureError_ForMultipleComposeFamilyAttributes() + { + // [AttributeUsage(AllowMultiple = false)] is enforced per exact attribute type, not across + // the Compose family - [Compose] and [Compose] are distinct types that each + // individually satisfy their own AllowMultiple = false, so nothing else stops stacking them. + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithMultipleComposeAttributes))!; + + var plan = BindingPlan.Build(MethodMetadataTestFactory.Create(method)); + + await Assert.That(plan.SignatureError).Contains("Compose"); + await Assert.That(plan.Parameters).IsEmpty(); + } + + [Test] + public async Task Build_ReportsASignatureError_ForMultipleComposeFamilyAttributes_OnAZeroParameterMethod() + { + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithMultipleComposeAttributesAndNoParameters))!; + + var plan = BindingPlan.Build(MethodMetadataTestFactory.Create(method)); + + await Assert.That(plan.SignatureError).Contains("Compose"); + await Assert.That(plan.Parameters).IsEmpty(); + } + + [Test] + public async Task Build_ResolvesTheNonGenericOverload_WhenAZeroParameterMethodNameIsAmbiguousWithAGenericOverload() + { + var method = typeof(SampleTestMethods).GetMethods() + .Single(candidate => candidate.Name == nameof(SampleTestMethods.AmbiguousZeroParameterMethod) && !candidate.IsGenericMethodDefinition); + + var plan = BindingPlan.Build(MethodMetadataTestFactory.Create(method)); + + await Assert.That(plan.SignatureError).IsNull(); + } + + [Test] + public async Task Build_ReportsASignatureError_ForTheGenericOverload_WhenAZeroParameterMethodNameIsAmbiguousWithANonGenericOverload() + { + // Proves ResolveMethodInfo's zero-parameter fallback doesn't throw AmbiguousMatchException + // for this shape (it would, without also filtering by generic arity) - the generic-method + // check below is reached and produces its own clear error, rather than the whole + // BindingPlan.Build call crashing first. + var method = typeof(SampleTestMethods).GetMethods() + .Single(candidate => candidate.Name == nameof(SampleTestMethods.AmbiguousZeroParameterMethod) && candidate.IsGenericMethodDefinition); + + var plan = BindingPlan.Build(MethodMetadataTestFactory.Create(method)); + + await Assert.That(plan.SignatureError).Contains("generic"); + } + + [Test] + public async Task Build_ReportsASignatureError_ForComposeStackedWithTheTwoTypeParameterForm() + { + // Detection (method.GetCustomAttributes()) already covers this form + // since ComposeAttribute derives from ComposeAttribute - this test proves + // it, rather than assuming the base-type relationship alone is enough. + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithComposeAndTwoTypeParameterComposeAttributes))!; + + var plan = BindingPlan.Build(MethodMetadataTestFactory.Create(method)); + + await Assert.That(plan.SignatureError).Contains("Compose"); + await Assert.That(plan.Parameters).IsEmpty(); + } + [Test] public async Task Build_DescriptorUsesParameterPositionNameAndDeclaringType() { diff --git a/test/Compono.TUnit.Tests/Compono.TUnit.Tests.csproj b/test/Compono.TUnit.Tests/Compono.TUnit.Tests.csproj index 3b0f77e..0a4b809 100644 --- a/test/Compono.TUnit.Tests/Compono.TUnit.Tests.csproj +++ b/test/Compono.TUnit.Tests/Compono.TUnit.Tests.csproj @@ -28,6 +28,11 @@ TUnit test run, unlike src/Compono.TUnit itself, which only authors against TUnit.Core's extensibility surface. --> + + diff --git a/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs new file mode 100644 index 0000000..078df8f --- /dev/null +++ b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs @@ -0,0 +1,309 @@ +using Compono.TUnit.Tests.Fixtures; + +namespace Compono.TUnit.Tests; + +// Compono.TUnit.AotSmokeTest carries the packaged-consumer, real-runner proof; these tests exercise +// ComposeAttribute{TProfile,TConfig}/ConfigProfileBinder directly via GetDataRowsAsync, the same +// fast, no-real-runner style ComposeAttributeBindingTests.cs uses. Mirrors +// Compono.XunitV3.Tests.ComposeAttributeConfigBindingTests exactly, adapted to TUnit's +// DataGeneratorMetadata-based GetDataRowsAsync entry point. +public sealed class ComposeAttributeConfigBindingTests +{ + [Test] + public async Task GetDataRowsAsync_MixesInlineValuesWithAProfileAppliedComposer() + { + // ComposeAttribute inherits Phase 0's own inline-value constructor unchanged + // (unlike ComposeAttribute, whose constructor arguments bind to TConfig + // instead) - this proves inline values still take precedence over composition even once a + // profile is applied to the underlying Composer. + var attribute = new ComposeAttribute(42); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.Simple))!; + + var data = await SingleRow(attribute, method); + + await Assert.That(data![0]).IsEqualTo(42); + await Assert.That(data[1]).IsTypeOf(); + } + + [Test] + public async Task GetDataRowsAsync_AppendsTheConfiguredSeed_WhenAFixedProfileFailsBeforeARowExists() + { + // ComposeAttribute.ApplyProfile runs while the base class's Lazy is + // still being built - before ComposeRow ever calls Composer.CreateRow, so there's no + // CompositionRow/row.Seed to read from yet when TProfile.Configure itself throws. Every + // Compono.TUnit-owned pre-composition failure ends with "Seed: {value}" - this proves that + // convention holds for the fixed-profile form too, not just ComposeAttribute's own identical wrapping. + var attribute = new ComposeAttribute { Seed = 492173 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("custom profile configuration failed").And + .WithMessageContaining("Seed: 492173"); + } + + [Test] + public async Task GetDataRowsAsync_ReportsTheNegativeSeedDiagnostic_NotTheProfileFailure_ForAFixedProfile_WhenBothApply() + { + // Seed = -1 combined with a throwing TProfile.Configure must report the documented + // negative-seed diagnostic, not the profile failure with "Seed: -1" embedded - the + // negative-seed check has to run before any profile work is even attempted. Mirrors + // ComposeAttribute's identical precedence test. + var attribute = new ComposeAttribute { Seed = -1 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("non-negative seed").And + .WithMessageContaining("-1"); + } + + [Test] + public async Task GetDataRowsAsync_ConstructsProfileFromConfig_AndComposesEveryTestParameter() + { + var attribute = new ComposeAttribute("from-config"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + var data = await SingleRow(attribute, method); + + await Assert.That(data).IsEquivalentTo(new object?[] { "from-config" }); + } + + [Test] + public async Task ConfigArguments_AreNeverBoundAsInlineValues() + { + // Profile configuration arguments and inline values are two entirely separate binding + // targets - this attribute's base-class InlineValues must stay empty regardless of how many + // profile configuration arguments are supplied, proving + // WithNonNullableReferenceParameter's own parameter is composed via the profile's + // registration in the test above, never bound directly from "from-config" the way an inline + // value would be. + var attribute = new ComposeAttribute("from-config"); + + await Assert.That(attribute.InlineValues).IsEmpty(); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenConfigTypeHasNoPublicConstructor() + { + var attribute = new ComposeAttribute(); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("exactly one public constructor").And + .WithMessageContaining("has 0"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenConfigTypeHasMultiplePublicConstructors() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("exactly one public constructor").And + .WithMessageContaining("has 2"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenProfileTypeHasNoConstructorAcceptingTheConfigType() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("must have exactly one public constructor accepting a single").And + .WithMessageContaining("TestConfig").And + .WithMessageContaining("has 0"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenTooFewProfileConfigurationArgumentsAreSupplied() + { + var attribute = new ComposeAttribute(); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("requires 1 profile configuration argument(s)").And + .WithMessageContaining("0 were supplied"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenTooManyProfileConfigurationArgumentsAreSupplied() + { + var attribute = new ComposeAttribute("one", "two"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("requires 1 profile configuration argument(s)").And + .WithMessageContaining("2 were supplied"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenConfigTypeIsAbstract() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + // AbstractConfig has exactly one public constructor - passes the "exactly one constructor" + // count check on its own, so this proves the explicit IsAbstract guard, not the count check + // (without it, this would throw MemberAccessException from ConstructorInfo.Invoke instead + // of the documented CompositionException). + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("abstract").And + .WithMessageContaining("cannot be used as profile configuration"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenProfileTypeIsAbstract() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("abstract").And + .WithMessageContaining("cannot be used as a profile"); + } + + [Test] + public async Task GetDataRowsAsync_AppendsTheConfiguredSeed_WhenProfileConstructionFailsBeforeARowExists() + { + // Every Compono.TUnit-owned pre-composition failure ends with "Seed: {value}" - this failure + // category is special because it's thrown from inside the base class's Lazy + // initialization, before ComposeRow ever calls Composer.CreateRow, so there is no + // CompositionRow/row.Seed to read from yet. Using an explicitly configured Seed proves the + // reported value is the one this attribute is actually configured with, not an unrelated + // throwaway number. + var attribute = new ComposeAttribute("value") { Seed = 492173 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("Seed: 492173"); + } + + [Test] + public async Task GetDataRowsAsync_AppendsAGeneratedSeed_WhenProfileConstructionFailsWithNoSeedConfigured() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + // No explicit seed configured, so only the convention (a trailing "Seed: ") + // is checked, not a specific value. + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("\nSeed: "); + } + + [Test] + public async Task GetDataRowsAsync_ReportsTheNegativeSeedDiagnostic_NotTheBinderFailure_WhenBothApply() + { + // Seed = -1 combined with an invalid profile/config shape must report the documented + // negative-seed diagnostic, not the binder failure with "Seed: -1" embedded - the + // negative-seed check has to run before any config/profile binding is even attempted. + var attribute = new ComposeAttribute("value") { Seed = -1 }; + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("non-negative seed").And + .WithMessageContaining("-1"); + } + + [Test] + public async Task GetDataRowsAsync_UnwrapsAndReportsTheOriginalException_WhenTheConfigConstructorThrows() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + // Proves ConstructorInfo.Invoke's TargetInvocationException wrapper was unwrapped - the + // caller sees ThrowingTestConfig's own actionable message (and the seed convention still + // applies), not a generic reflection failure. + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("custom validation failed for 'value'").And + .WithMessageContaining("Seed: "); + } + + [Test] + public async Task GetDataRowsAsync_UnwrapsAndReportsTheOriginalException_WhenTheProfileConstructorThrows() + { + var attribute = new ComposeAttribute("value"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("custom validation failed for 'value'").And + .WithMessageContaining("Seed: "); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenAProfileConfigurationArgumentHasAnIncompatibleType() + { + var attribute = new ComposeAttribute(42); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("not assignable to"); + } + + [Test] + public async Task GetDataRowsAsync_Throws_WhenANullProfileConfigurationArgumentTargetsANonNullableParameter() + { + var attribute = new ComposeAttribute((object?)null); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + await Assert.That(() => SingleRow(attribute, method)).Throws() + .WithMessageContaining("is null, but the parameter is not nullable"); + } + + [Test] + public async Task GetDataRowsAsync_AcceptsANonNullValueTypeArgument_ForANullableValueTypeParameter() + { + // 42 boxes as System.Int32, not System.Nullable (a CLR nullable-boxing rule) - + // this proves ConfigProfileBinder unwraps Nullable before the assignability check the + // same way ComposeAttribute's own inline-value binding already does, rather than the check + // wrongly rejecting a valid int argument for an int? config constructor parameter. + var attribute = new ComposeAttribute(42); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableValueParameter))!; + + var data = await SingleRow(attribute, method); + + await Assert.That(data).IsEquivalentTo(new object?[] { 42 }); + } + + [Test] + public async Task GetDataRowsAsync_AcceptsANullProfileConfigurationArgument_ForANullableParameter() + { + var attribute = new ComposeAttribute((object?)null); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + var data = await SingleRow(attribute, method); + + await Assert.That(data).IsEquivalentTo(new object?[] { "null" }); + } + + [Test] + public async Task GetDataRowsAsync_ConstructsTheProfileExactlyOnce_AcrossRepeatedCalls() + { + var attribute = new ComposeAttribute("from-config"); + var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!; + + // ApplyProfile (and, inside it, ConfigProfileBinder's reflection) only ever runs the first + // time the base class's Lazy is evaluated - asserting the same Composer instance is + // reused across repeated calls is what proves the config/profile construction ran exactly + // once, not once per call. + var composerBeforeFirstCall = attribute.GetComposer(); + + await SingleRow(attribute, method); + await SingleRow(attribute, method); + + await Assert.That(attribute.GetComposer()).IsSameReferenceAs(composerBeforeFirstCall); + } + + private static async Task SingleRow(ComposeAttribute attribute, System.Reflection.MethodInfo method) + { + var metadata = DataGeneratorMetadataTestFactory.Create(method); + var factories = new List>>(); + + await foreach (var factory in attribute.GetDataRowsAsync(metadata)) + factories.Add(factory); + + var single = factories.Single(); + return await single(); + } +} diff --git a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs index 12eee69..6e6706a 100644 --- a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs @@ -13,6 +13,14 @@ public static void Simple(int number, string text) { } + public static void WithNonNullableReferenceParameter(string value) + { + } + + public static void WithNonNullableValueParameter(int value) + { + } + public static void WithShared([Shared] string repository, string other) { } @@ -51,4 +59,188 @@ public static void WithRefStructParameter(Span value) public static void Generic(T value) { } + + // TUnit's own analyzer for a data-source attribute without [Test] is expected and suppressed + // here - these methods are never run as real tests, only reflected over via + // typeof(...).GetMethod(...) for BindingPlan.Build's multiple-Compose-family-attribute + // signature check, same as every other method in this fixture class per the type-level comment + // above. Mirrors Compono.XunitV3.Tests.Fixtures.SampleTestMethods' identical stacked-attribute + // fixtures. + [Compose] + [Compose] + public static void WithMultipleComposeAttributes(int value) + { + } + + // A zero-parameter method has no ParameterMetadata to read ReflectionInfo.Member from - + // BindingPlan's own stacked-attribute lookup falls back to an arity-aware GetMethods() filter + // (name, zero declared parameters, and generic arity together) for exactly this shape; this + // fixture exercises that fallback path. See AmbiguousZeroParameterMethod below for why generic + // arity must be part of that filter, not just parameter count. + [Compose] + [Compose] + public static void WithMultipleComposeAttributesAndNoParameters() + { + } + + // Two zero-parameter overloads sharing a name, distinguished only by generic arity - + // BindingPlan's own zero-parameter method-resolution fallback matches by parameter *types* only + // (Type.EmptyTypes), which doesn't distinguish these; without also filtering by generic arity, + // Type.GetMethod(name, Type.EmptyTypes) throws AmbiguousMatchException for this exact shape, + // crashing before the generic-method signature check even runs (Codex review). + public static void AmbiguousZeroParameterMethod() + { + } + + public static void AmbiguousZeroParameterMethod() + { + } + + // Same reasoning as WithMultipleComposeAttributes above, pairing the two-type-parameter form + // with the plain one instead of the one-type-parameter form - proves BindingPlan's stacking + // detection (and its message) covers ComposeAttribute too, not just the + // original two forms. + [Compose] + [Compose("value")] + public static void WithComposeAndTwoTypeParameterComposeAttributes(int value) + { + } + + public sealed class TestProfile : ICompositionProfile + { + public void Configure(CompositionBuilder builder) => builder.Register(() => "from-profile"); + } + + // ComposeAttribute{TProfile}'s own ApplyProfile failure case - a fixed, default-constructed + // profile whose Configure itself throws, proving that failure is wrapped with the "Seed: {value}" + // convention the same way ComposeAttribute{TProfile,TConfig}'s identical ApplyProfile failure + // already was (Codex review). + public sealed class ThrowingConfigureTestProfile : ICompositionProfile + { + public void Configure(CompositionBuilder builder) => throw new CompositionException("custom profile configuration failed"); + } + + // ComposeAttribute{TProfile,TConfig} fixtures - a config record with exactly one public + // constructor (the supported shape), a profile with exactly one public constructor accepting + // exactly that config type, and one broken variant per ConfigProfileBinder failure mode. + // Mirrors Compono.XunitV3.Tests.Fixtures.SampleTestMethods' identical set. + + public sealed record TestConfig(string Value); + + public sealed class ParameterizedTestProfile : ICompositionProfile + { + public ParameterizedTestProfile(TestConfig config) => Config = config; + + public TestConfig Config { get; } + + public void Configure(CompositionBuilder builder) => builder.Register(() => Config.Value); + } + + public sealed record NullableTestConfig(string? Value); + + public sealed class NullableParameterizedTestProfile : ICompositionProfile + { + public NullableParameterizedTestProfile(NullableTestConfig config) => Config = config; + + public NullableTestConfig Config { get; } + + public void Configure(CompositionBuilder builder) => builder.Register(() => Config.Value ?? "null"); + } + + // A non-null value-typed profile configuration argument for a Nullable constructor parameter + // - proves ConfigProfileBinder's Nullable-boxing unwrap (a non-null int? boxes as a boxed + // int, not a boxed int?) the same way ComposeAttribute's own inline-value binding already covers + // it, retargeted at a config type's constructor instead of a test method's parameters. + public sealed record NullableIntTestConfig(int? Value); + + public sealed class NullableIntParameterizedTestProfile : ICompositionProfile + { + public NullableIntParameterizedTestProfile(NullableIntTestConfig config) => Config = config; + + public NullableIntTestConfig Config { get; } + + public void Configure(CompositionBuilder builder) => builder.Register(() => Config.Value ?? -1); + } + + // Zero public constructors - ConfigProfileBinder.BindConfig's "exactly one" check, zero case. + public sealed class ConfigWithNoPublicConstructor + { + private ConfigWithNoPublicConstructor() + { + } + } + + // Two public constructors - ConfigProfileBinder.BindConfig's "exactly one" check, ambiguous case. + public sealed class ConfigWithMultiplePublicConstructors + { + public ConfigWithMultiplePublicConstructors(string value) => Value = value; + + public ConfigWithMultiplePublicConstructors(string value, string extra) + { + Value = value; + Extra = extra; + } + + public string Value { get; } + + public string? Extra { get; } + } + + // No constructor accepting exactly one TestConfig parameter - ConfigProfileBinder.BuildProfile's + // "exactly one matching constructor" check, zero-match case. + public sealed class ProfileWithoutMatchingConstructor : ICompositionProfile + { + public void Configure(CompositionBuilder builder) + { + } + } + + // Abstract with an otherwise-qualifying public constructor - ConfigProfileBinder.BindConfig's + // abstract-type rejection, not the "exactly one constructor" count check (without the explicit + // IsAbstract check, this shape would pass the count check and then throw + // MemberAccessException from ConstructorInfo.Invoke instead of the documented CompositionException). + public abstract class AbstractConfig + { + // Public, not protected - an abstract type's constructor accessibility is independent of + // whether the type itself can be instantiated; C# and the CLR both allow a public constructor + // on an abstract type (only a derived type can actually call it), which is exactly what makes + // this shape reach ConstructorInfo.Invoke without the explicit IsAbstract guard. + public AbstractConfig(string value) => Value = value; + + public string Value { get; } + } + + // Abstract with an otherwise-qualifying public constructor accepting TestConfig - + // ConfigProfileBinder.BuildProfile's abstract-type rejection, same reasoning as AbstractConfig + // above. + public abstract class AbstractProfile : ICompositionProfile + { + public AbstractProfile(TestConfig config) => Config = config; + + public TestConfig Config { get; } + + public void Configure(CompositionBuilder builder) + { + } + } + + // A single public constructor that itself throws - ConfigProfileBinder.Invoke's + // TargetInvocationException-unwrapping, config-construction case (ConstructorInfo.Invoke wraps + // a constructor-thrown exception in TargetInvocationException; without unwrapping, ApplyProfile's + // own catch (CompositionException) never observes this). + public sealed class ThrowingTestConfig + { + public ThrowingTestConfig(string value) => throw new CompositionException($"custom validation failed for '{value}'"); + } + + // Same reasoning as ThrowingTestConfig above, but for the profile-construction call site instead + // of the config-construction one. + public sealed class ThrowingTestProfile : ICompositionProfile + { + public ThrowingTestProfile(TestConfig config) => throw new CompositionException($"custom validation failed for '{config.Value}'"); + + public void Configure(CompositionBuilder builder) + { + } + } }