From 3540365026e28976fe5ff9c2478a47cc28496cd9 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 10:28:19 -0400 Subject: [PATCH 01/14] feat(tunit): add ComposeAttribute/ profile variants Mirrors Compono.XunitV3's profile-attribute family exactly, including ConfigProfileBinder's reflection-based TConfig/TProfile construction. Extends the AOT smoke test to exercise the config-generic form, which surfaced a real Native AOT gap (ADR-0041 Amendment 1): the trimmer strips constructors on closed generic type arguments unless annotated, so ConfigProfileBinder failed at runtime with "0 public constructors" on a type that plainly has one. Fixed with DynamicallyAccessedMembers annotations end to end. Co-Authored-By: Claude Sonnet 5 --- .../Binding/ConfigProfileBinder.cs | 191 ++++++++++++++++++ .../ComposeAttribute{TProfile,TConfig}.cs | 117 +++++++++++ .../ComposeAttribute{TProfile}.cs | 33 +++ test/Compono.TUnit.AotSmokeTest/Program.cs | 65 ++++-- 4 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 src/Compono.TUnit/Binding/ConfigProfileBinder.cs create mode 100644 src/Compono.TUnit/ComposeAttribute{TProfile,TConfig}.cs create mode 100644 src/Compono.TUnit/ComposeAttribute{TProfile}.cs 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/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..d23b593 --- /dev/null +++ b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs @@ -0,0 +1,33 @@ +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) => builder.AddProfile(); +} 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 From 14289d7714fd02ed94348082050a57801e5acd72 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 10:32:34 -0400 Subject: [PATCH 02/14] feat(tunit): stacked Compose-family attribute rejection + profile-binding test coverage BindingPlan.ValidateSignature now detects more than one Compose-family attribute stacked on a method, mirroring Compono.XunitV3's identical check - adapted to what MethodMetadata exposes: a parameter's ReflectionInfo.Member gives the declaring MethodInfo directly, with a Type.GetMethod(name, Type.EmptyTypes) fallback for zero-parameter methods. Adds ComposeAttributeConfigBindingTests.cs (ConfigProfileBinder coverage, mirroring Compono.XunitV3.Tests) and stacked-attribute BindingPlanTests cases, both parameter and zero-parameter method shapes. Co-Authored-By: Claude Sonnet 5 --- src/Compono.TUnit/Binding/BindingPlan.cs | 37 ++- test/Compono.TUnit.Tests/BindingPlanTests.cs | 39 +++ .../ComposeAttributeConfigBindingTests.cs | 261 ++++++++++++++++++ .../Fixtures/SampleTestMethods.cs | 168 +++++++++++ 4 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs diff --git a/src/Compono.TUnit/Binding/BindingPlan.cs b/src/Compono.TUnit/Binding/BindingPlan.cs index 770d7ca..6220d34 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, disambiguated unambiguously via Type.EmptyTypes since + // a zero-parameter overload's signature has nothing else to overload on. + 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,18 @@ 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 Type.GetMethod(name, Type.EmptyTypes) + // lookup instead - unambiguous specifically because a zero-parameter overload's signature has + // nothing else to disambiguate on. + private static MethodInfo? ResolveMethodInfo(MethodMetadata testInformation, ParameterMetadata[] parameters) => + parameters.Length > 0 + ? parameters[0].ReflectionInfo.Member as MethodInfo + : testInformation.Class.Type.GetMethod( + testInformation.Name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly, + Type.EmptyTypes); } diff --git a/test/Compono.TUnit.Tests/BindingPlanTests.cs b/test/Compono.TUnit.Tests/BindingPlanTests.cs index a1468c6..570cb2d 100644 --- a/test/Compono.TUnit.Tests/BindingPlanTests.cs +++ b/test/Compono.TUnit.Tests/BindingPlanTests.cs @@ -124,6 +124,45 @@ 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_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/ComposeAttributeConfigBindingTests.cs b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs new file mode 100644 index 0000000..bf45606 --- /dev/null +++ b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs @@ -0,0 +1,261 @@ +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_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..f18b4bf 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,164 @@ 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 a direct Type.GetMethod(name, + // Type.EmptyTypes) call for exactly this shape; this fixture exercises that fallback path. + [Compose] + [Compose] + public static void WithMultipleComposeAttributesAndNoParameters() + { + } + + // 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,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) + { + } + } } From 88f00bf93a46154b3fb24b467b06d0922e621980 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 10:34:40 -0400 Subject: [PATCH 03/14] test(tunit): NSubstituteTests.cs - the plan's Goal-section scenario, run for real under TUnit Adds Compono.NSubstitute to Compono.TUnit.SampleTests' local-feed pack chain and mirrors Compono.XunitV3.SampleTests/NSubstituteTests.cs exactly: [Shared] IOrderRepository composed via [Compose], UseNSubstitute() wired through the profile, reused inside a composed constructor parameter - the real packaged Compono.NSubstitute -> Compono.TUnit -> Compono dependency chain, under a real TUnit runner. Co-Authored-By: Claude Sonnet 5 --- .../Compono.TUnit.SampleTests.csproj | 8 ++- .../NSubstituteTests.cs | 51 +++++++++++++++++++ .../pack-to-local-feed.sh | 14 ++--- 3 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 test/Compono.TUnit.SampleTests/NSubstituteTests.cs diff --git a/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj b/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj index 3cfd5cc..4a25c5a 100644 --- a/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj +++ b/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj @@ -42,17 +42,21 @@ + + - + 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 From 0a7fdd18f2bd882f0fc1b2c9fad390bda45bd0f1 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 10:37:41 -0400 Subject: [PATCH 04/14] docs(tunit): document Phase 1 profile-attribute family, close out PLAN-0040 Phase 1 Extends docs/packages/compono-tunit.md and skills/compono/references/tunit.md with [Compose]/[Compose] usage and the real stacked-attribute rejection behavior, replacing the earlier "not shipped yet"/"stacking is undefined" language. Adds the missing inline-values- combined-with-a-profile test case PLAN-0040 Phase 1 called for. Checks off every Phase 1 task and records the AOT-gate finding in the plan's Notes. Co-Authored-By: Claude Sonnet 5 --- docs/packages/compono-tunit.md | 64 +++++++---- .../0040-compono-tunit-package-design.md | 64 +++++++++-- skills/compono/references/tunit.md | 107 ++++++++++++++---- .../ComposeAttributeConfigBindingTests.cs | 16 +++ 4 files changed, 202 insertions(+), 49 deletions(-) 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..0b70040 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,49 @@ 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 `Type.GetMethod(name, +Type.EmptyTypes)` fallback for a zero-parameter method) and counting +`ComposeAttribute`-derived attributes on it. + +**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/skills/compono/references/tunit.md b/skills/compono/references/tunit.md index e2fa750..f543453 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,73 @@ 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, cached + `CompositionException` at binding-plan-construction time, 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 +126,22 @@ 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 +## 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 a direct `GetMethod` +lookup for a zero-parameter method) and counts `ComposeAttribute`-derived +attributes on it. The identical attribute type twice on one method **is** +a compiler error (`AllowMultiple=false`). -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. +**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 +158,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/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs index bf45606..defaf41 100644 --- a/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs +++ b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs @@ -9,6 +9,22 @@ namespace Compono.TUnit.Tests; // 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_ConstructsProfileFromConfig_AndComposesEveryTestParameter() { From 75424a5710b1b6987461af03fef454bdea484f5f Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 10:44:52 -0400 Subject: [PATCH 05/14] docs(api): regenerate API reference for Compono.TUnit's new profile attributes ComposeAttribute/ComposeAttribute are new public types - the CI drift-detection check (generate-api-reference.sh) flagged the docs/reference/api snapshot as stale for this PR. Co-Authored-By: Claude Sonnet 5 --- .../Compono.TUnit.ComposeAttribute.md | 4 ++ ...ile,TConfig_.ComposeAttribute(object[]).md | 21 ++++++ ...Unit.ComposeAttribute_TProfile,TConfig_.md | 70 +++++++++++++++++++ ...te_TProfile_.ComposeAttribute(object[]).md | 18 +++++ ...ompono.TUnit.ComposeAttribute_TProfile_.md | 34 +++++++++ .../api/Compono.TUnit/Compono.TUnit.md | 2 + 6 files changed, 149 insertions(+) create mode 100644 docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.ComposeAttribute(object[]).md create mode 100644 docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md create mode 100644 docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.ComposeAttribute(object[]).md create mode 100644 docs/reference/api/Compono.TUnit/Compono.TUnit.ComposeAttribute_TProfile_.md 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\. | From e36facd4b0cba6e00bf6d685f25c85166a700f86 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 10:47:51 -0400 Subject: [PATCH 06/14] fix(tunit): disambiguate generic arity in zero-parameter method lookup, fix package description BindingPlan.ResolveMethodInfo's zero-parameter fallback 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. Now filters GetMethods() by both zero declared parameters and this test's own GenericTypeCount. Also restores Compono.TUnit's NuGet description to describe the full shipped attribute family - it still said the profile variants "ship in a later phase" despite Phase 1 having just shipped them. Co-Authored-By: Claude Sonnet 5 --- src/Compono.TUnit/Binding/BindingPlan.cs | 30 ++++++++++++------- src/Compono.TUnit/Compono.TUnit.csproj | 2 +- test/Compono.TUnit.Tests/BindingPlanTests.cs | 26 ++++++++++++++++ .../Fixtures/SampleTestMethods.cs | 13 ++++++++ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/Compono.TUnit/Binding/BindingPlan.cs b/src/Compono.TUnit/Binding/BindingPlan.cs index 6220d34..61d5a86 100644 --- a/src/Compono.TUnit/Binding/BindingPlan.cs +++ b/src/Compono.TUnit/Binding/BindingPlan.cs @@ -143,14 +143,24 @@ internal static string MethodDisplayName(MethodMetadata testInformation) => // 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 Type.GetMethod(name, Type.EmptyTypes) - // lookup instead - unambiguous specifically because a zero-parameter overload's signature has - // nothing else to disambiguate on. - private static MethodInfo? ResolveMethodInfo(MethodMetadata testInformation, ParameterMetadata[] parameters) => - parameters.Length > 0 - ? parameters[0].ReflectionInfo.Member as MethodInfo - : testInformation.Class.Type.GetMethod( - testInformation.Name, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly, - Type.EmptyTypes); + // 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/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/test/Compono.TUnit.Tests/BindingPlanTests.cs b/test/Compono.TUnit.Tests/BindingPlanTests.cs index 570cb2d..5c8ec16 100644 --- a/test/Compono.TUnit.Tests/BindingPlanTests.cs +++ b/test/Compono.TUnit.Tests/BindingPlanTests.cs @@ -149,6 +149,32 @@ public async Task Build_ReportsASignatureError_ForMultipleComposeFamilyAttribute 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() { diff --git a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs index f18b4bf..a78706a 100644 --- a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs @@ -81,6 +81,19 @@ 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 From ff452f9b6277ec111cb37cd545c6a10ebc7036c2 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:03:36 -0400 Subject: [PATCH 07/14] fix(ci): pin and explicitly reference Microsoft.Testing.Platform.MSBuild for TUnit test projects Root cause of the recurring CI flake ("Could not load ... Microsoft.Testing .Extensions.MSBuild, Version=2.3.3.0 ... cannot find the file", a different TFM each run): that assembly ships in Microsoft.Testing.Platform.MSBuild, which had no Central-Package-Management-pinned PackageVersion in this repo. TestingPlatformDotnetTestSupport=true relies on the .NET SDK implicitly auto-adding that package reference, and under CPM with no pinned version that implicit reference resolved inconsistently across a solution-wide parallel multi-TFM build - never reproducible via a single-project local build, exactly matching what CI showed three times in a row (net10.0, then net8.0+net11.0, then net11.0 again). Compono.XunitV3.Tests never hit this because it already references Microsoft.Testing.Platform (pinned) explicitly via test/Directory.Build .targets' shared xUnit-only ItemGroup. Compono.TUnit.Tests and Compono.TUnit.SampleTests had no equivalent explicit, pinned reference - added Microsoft.Testing.Platform.MSBuild (matching Microsoft.Testing .Platform's own 2.3.3 pin) to both, plus the missing PackageVersion entry. Verified locally: Microsoft.Testing.Extensions.MSBuild.dll now present in both projects' build output for all four TFMs (net8.0/9.0/10.0/11.0), full solution build clean, Compono.TUnit.Tests 196/196 passing. Co-Authored-By: Claude Sonnet 5 --- Directory.Packages.props | 13 +++++++++++++ .../Compono.TUnit.SampleTests.csproj | 6 ++++++ test/Compono.TUnit.Tests/Compono.TUnit.Tests.csproj | 5 +++++ 3 files changed, 24 insertions(+) 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/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj b/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj index 4a25c5a..b4a8028 100644 --- a/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj +++ b/test/Compono.TUnit.SampleTests/Compono.TUnit.SampleTests.csproj @@ -42,6 +42,12 @@ + + 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. --> + + From e3db9bcca6196d034e7dbf80b0c019f5165bc00c Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:06:24 -0400 Subject: [PATCH 08/14] docs(tunit): correct stale comment describing the fixed AmbiguousMatchException bug ValidateSignature's comment (and the plan's own Phase 1 Notes entry) still described the original Type.EmptyTypes-only zero-parameter lookup after e36facd replaced it with an arity-aware filter - contradicting the actual implementation and risking a future "simplification" back to the broken version. Co-Authored-By: Claude Sonnet 5 --- .../0040-compono-tunit-package-design.md | 32 +++++++++++++++++-- src/Compono.TUnit/Binding/BindingPlan.cs | 4 +-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/plans/0040-compono-tunit-package-design.md b/docs/plans/0040-compono-tunit-package-design.md index 0b70040..14993c9 100644 --- a/docs/plans/0040-compono-tunit-package-design.md +++ b/docs/plans/0040-compono-tunit-package-design.md @@ -844,9 +844,35 @@ complete pending PR review/merge. 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 `Type.GetMethod(name, -Type.EmptyTypes)` fallback for a zero-parameter method) and counting -`ComposeAttribute`-derived attributes on it. +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 diff --git a/src/Compono.TUnit/Binding/BindingPlan.cs b/src/Compono.TUnit/Binding/BindingPlan.cs index 61d5a86..7070457 100644 --- a/src/Compono.TUnit/Binding/BindingPlan.cs +++ b/src/Compono.TUnit/Binding/BindingPlan.cs @@ -96,8 +96,8 @@ internal static string MethodDisplayName(MethodMetadata testInformation) => // 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, disambiguated unambiguously via Type.EmptyTypes since - // a zero-parameter overload's signature has nothing else to overload on. + // 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; From 3a6e7cb91ea592906793a7decc109db21831b3ab Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:15:26 -0400 Subject: [PATCH 09/14] fix(ci): pin exact .NET 11 preview SDK in PR build workflow to stop CI drift Root cause of the recurring Microsoft.Testing.Extensions.MSBuild.dll flake (previous commit ff452f9's fix was real but insufficient): the workflow's floating "11.0.x" dotnetVersion input let actions/setup-dotnet install whatever the newest 11.0 preview SDK happens to be. CI was actually running 11.0.100-preview.7.26381.103 - a newer build than global.json's own pinned 11.0.100-preview.6.26359.118 - and global.json's rollForward: latestFeature policy silently accepted the mismatch instead of failing loudly. Verified locally: preview.6 (installed here) builds every project to its own ordinary bin/// directory, exactly as expected. preview.7's CI logs show every project across the whole .slnx solution - libraries and test projects alike - building into one shared publish// directory instead, a new preview-SDK behavior change that creates exactly the kind of concurrent-write race (many projects' builds writing runtime dependencies into the same folder) that would explain a runtime dependency nondeterministically going missing for a different project/TFM combination on every run. Pins the PR build workflow's dotnetVersion to the exact SDK build global.json already specifies, so CI can no longer drift onto an untested newer preview. package-validation.yaml/docs.yml/publish-*.yaml still use the floating 11.0.x pattern - not touched here since they don't hit this same failure mode and deserve their own audit, not a blind copy-paste fix. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/pr-build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f0615a84f4104d94ea345c756d2181bd279c982d Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:24:19 -0400 Subject: [PATCH 10/14] fix(ci): stop a leaked CI env var from collapsing every project's build output into one shared directory The actual root cause of the recurring Microsoft.Testing.Extensions.MSBuild .dll flake (both previous fixes - ff452f9's explicit package reference and 3a6e7cb's exact SDK pin - were real improvements but not the cause): devops-templates' reusable PR-build workflow sets a GITHUB_ENV variable named "outputPath" for its own later Blazor/Lambda publish steps, but that variable stays a process env var for every subsequent step in the same job - including this repo's own plain "dotnet build"/"dotnet test" steps. MSBuild auto-imports environment variables as property values (case-insensitively) whenever no project file has already set that property, so the leaked "outputPath" env var satisfied MSBuild's standard OutputPath property for every project in the solution, collapsing all of them - libraries and test projects alike, every TFM - into one shared directory instead of each project's own bin///. Reproduced locally by setting the same env var before `dotnet build Compono.slnx`: every project's output merged into the env var's target directory, exactly matching CI's observed publish// layout (which never appears locally without it) - a shared directory across many concurrently-building projects is exactly the kind of setup where one project's copy of a shared runtime dependency can lose a race against another's, matching the flake's own signature (same exception, a different TFM/project combination on every run, never reproducible via a single local build). Directory.Build.props now unconditionally resets OutputPath to empty, letting the SDK's own conditional default-path computation fire fresh regardless of what the CI environment leaks in - restoring isolated per-project/per-TFM output. Verified locally under the exact leaked-env- var condition: no shared directory, Microsoft.Testing.Extensions.MSBuild .dll present for all four TFMs, full solution build + `dotnet test --solution` both 1873/1873 passing, and `dotnet pack` unaffected. Co-Authored-By: Claude Sonnet 5 --- Directory.Build.props | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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 From e4ee05db2dad3e0be5269f0dd76d774fa149548c Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:31:58 -0400 Subject: [PATCH 11/14] fix(tunit): append seed when ComposeAttribute's fixed profile fails 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 yet if TProfile.Configure itself throws. This form's ApplyProfile called builder.AddProfile() unwrapped, so even a configured Seed went unreported - unlike ComposeAttribute's identical failure path, which already wraps with CompositionException.WithSeedInMessage. Same gap exists in Compono.XunitV3.ComposeAttribute{TProfile} (mirrored faithfully from there) - worth its own follow-up, out of scope here. Co-Authored-By: Claude Sonnet 5 --- .../ComposeAttribute{TProfile}.cs | 20 ++++++++++++++++++- .../ComposeAttributeConfigBindingTests.cs | 17 ++++++++++++++++ .../Fixtures/SampleTestMethods.cs | 9 +++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Compono.TUnit/ComposeAttribute{TProfile}.cs b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs index d23b593..79de484 100644 --- a/src/Compono.TUnit/ComposeAttribute{TProfile}.cs +++ b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs @@ -29,5 +29,23 @@ public ComposeAttribute(params object?[] inlineValues) : base(inlineValues) { } - internal override void ApplyProfile(CompositionBuilder builder) => builder.AddProfile(); + internal override void ApplyProfile(CompositionBuilder builder) + { + 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.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs index defaf41..b9ffb76 100644 --- a/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs +++ b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs @@ -25,6 +25,23 @@ public async Task GetDataRowsAsync_MixesInlineValuesWithAProfileAppliedComposer( 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_ConstructsProfileFromConfig_AndComposesEveryTestParameter() { diff --git a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs index a78706a..d3036a3 100644 --- a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs @@ -109,6 +109,15 @@ 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. From 2cf355451ff5c74a2c764764e97d571c80aa4632 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:46:42 -0400 Subject: [PATCH 12/14] fix(tunit): validate negative seeds before applying a fixed profile ComposeAttribute.ApplyProfile's new seed-wrapping try/catch (e4ee05d) let a negative configured seed slip through to the profile- failure path when TProfile.Configure also throws - reporting the profile failure with "Seed: -1" embedded instead of the documented negative-seed diagnostic. The negative-seed check must run before any profile work is attempted, matching ComposeAttribute's identical precedence. Co-Authored-By: Claude Sonnet 5 --- src/Compono.TUnit/ComposeAttribute{TProfile}.cs | 12 ++++++++++++ .../ComposeAttributeConfigBindingTests.cs | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Compono.TUnit/ComposeAttribute{TProfile}.cs b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs index 79de484..7a072d0 100644 --- a/src/Compono.TUnit/ComposeAttribute{TProfile}.cs +++ b/src/Compono.TUnit/ComposeAttribute{TProfile}.cs @@ -31,6 +31,18 @@ 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(); diff --git a/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs index b9ffb76..078df8f 100644 --- a/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs +++ b/test/Compono.TUnit.Tests/ComposeAttributeConfigBindingTests.cs @@ -42,6 +42,21 @@ await Assert.That(() => SingleRow(attribute, method)).Throws'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() { From 05c85e688d88445bb2fe348e70ab2a5c743fdba9 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 11:59:02 -0400 Subject: [PATCH 13/14] docs(tunit): correct the skill doc's own stale zero-parameter lookup claim Same drift as the BindingPlan.cs comment fixed in e3db9bc, missed in that pass - skills/compono/references/tunit.md still described the zero-parameter fallback as a plain GetMethod(name, Type.EmptyTypes) call, which would throw AmbiguousMatchException for a class declaring both Run() and Run(). Corrected to describe the real arity-aware GetMethods() filter. Co-Authored-By: Claude Sonnet 5 --- skills/compono/references/tunit.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/skills/compono/references/tunit.md b/skills/compono/references/tunit.md index f543453..0e4c609 100644 --- a/skills/compono/references/tunit.md +++ b/skills/compono/references/tunit.md @@ -133,10 +133,15 @@ 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 a direct `GetMethod` -lookup for a zero-parameter method) and counts `ComposeAttribute`-derived -attributes on it. The identical attribute type twice on one method **is** -a compiler error (`AllowMultiple=false`). +(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 From a2e8c52b2bec42b1bac8cd9d41ca5f34ecf39cdd Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 12 Aug 2026 12:48:39 -0400 Subject: [PATCH 14/14] docs(tunit): fix remaining stale lifecycle/lookup claims - tunit.md's ComposeAttribute section described a bad TConfig/TProfile constructor shape as a "binding-plan-construction time" failure - it's actually raised during composer/profile initialization (ApplyProfile, inside the cached Lazy), before BindingPlan is ever built. - SampleTestMethods.cs's own fixture comment still described the zero-parameter fallback as a plain Type.GetMethod(name, Type.EmptyTypes) call - the third copy of this same stale claim found across the diff, corrected to describe the real arity-aware GetMethods() filter. Co-Authored-By: Claude Sonnet 5 --- skills/compono/references/tunit.md | 10 ++++++---- test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/skills/compono/references/tunit.md b/skills/compono/references/tunit.md index 0e4c609..9c4d41a 100644 --- a/skills/compono/references/tunit.md +++ b/skills/compono/references/tunit.md @@ -89,10 +89,12 @@ 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, cached - `CompositionException` at binding-plan-construction time, not a compile - error (`[Compose]`'s `new()` constraint doesn't carry over to - this form - see `docs/adr/0036-parameterized-composition-profile-selection.md`). + 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 diff --git a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs index d3036a3..6e6706a 100644 --- a/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs +++ b/test/Compono.TUnit.Tests/Fixtures/SampleTestMethods.cs @@ -73,8 +73,10 @@ 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 a direct Type.GetMethod(name, - // Type.EmptyTypes) call for exactly this shape; this fixture exercises that fallback path. + // 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()