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
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