diff --git a/Compono.slnx b/Compono.slnx index e023dd0..5ec5559 100644 --- a/Compono.slnx +++ b/Compono.slnx @@ -3,6 +3,7 @@ + diff --git a/docs/mvp.md b/docs/mvp.md index 3cb86d4..86b4511 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -270,10 +270,12 @@ package: substitutable-shape rules including delegate types, `NSubstituteOptions diagnostics). **ADR-0024's core engine extension point is implemented (PLAN-0005 Phase 0)** — `builder.AddSemanticProvider(...)`/ `builder.AddTestDoubleProvider(...)` are real, tested public API today. -**`Compono.NSubstitute` itself (ADR-0025) is not yet implemented** — see +**`Compono.NSubstitute` itself (ADR-0025) is implemented (PLAN-0005 Phase 1)** +— `NSubstituteProvider`/`NSubstituteOptions`/`UseNSubstitute()` are real code, +but not yet test-covered or end-to-end verified (PLAN-0005 Phase 2) — see [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for the phase-by-phase tracker; this milestone's own exit criteria aren't met until -that package ships. +that verification lands. ### Scope diff --git a/docs/plans/0005-milestone-5-nsubstitute-integration.md b/docs/plans/0005-milestone-5-nsubstitute-integration.md index 3a12435..16784e0 100644 --- a/docs/plans/0005-milestone-5-nsubstitute-integration.md +++ b/docs/plans/0005-milestone-5-nsubstitute-integration.md @@ -123,15 +123,15 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. ### Phase 1: `Compono.NSubstitute` package (ADR-0025) -**Status:** Not Started +**Status:** Done -- [ ] New `src/Compono.NSubstitute/Compono.NSubstitute.csproj` (matching +- [x] New `src/Compono.NSubstitute/Compono.NSubstitute.csproj` (matching `Compono.XunitV3.csproj`'s TFM/packaging shape — `PackageReference` to `Compono` + `NSubstitute`, `PrivateAssets="none"` on the `Compono` reference per PLAN-0004 Phase 3's real packaging-bug lesson). -- [ ] `NSubstituteOptions`: `SubstituteAbstractClasses` (`bool`, default +- [x] `NSubstituteOptions`: `SubstituteAbstractClasses` (`bool`, default `true`). -- [ ] `NSubstituteProvider : ICompositionValueProvider`: the +- [x] `NSubstituteProvider : ICompositionValueProvider`: the `IsSubstitutable` static check (interface / delegate / conditionally unsealed-abstract-class), `Substitute.For(Type[], object[])` call for a match, `NotHandled` otherwise, no `context.Resolve()` call @@ -146,7 +146,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. **not** `typeof(Delegate).IsAssignableFrom(requestedType)` — the latter wrongly matches the non-substitutable `Delegate`/ `MulticastDelegate` base types themselves. -- [ ] `CompositionBuilderExtensions.UseNSubstitute()`/ +- [x] `CompositionBuilderExtensions.UseNSubstitute()`/ `UseNSubstitute(Action)` extension methods (C# 14 extension-block syntax, per `coding-standards.md`). @@ -220,12 +220,22 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. versus `Compono.NSubstitute` itself not yet — done early, same PR #28 review round. Delegate substitution already in the scope list from the design-phase draft. -- [ ] `docs/mvp.md`: mark this milestone's exit criteria met once - `Compono.NSubstitute` actually ships (Phase 1/2) — still open. +- [x] `docs/mvp.md`: updated again in the Phase 1 PR (Codex, P2, same + pattern as the PR #28 round above) — `Compono.NSubstitute` (ADR-0025) + now says "implemented (PLAN-0005 Phase 1)," with test + coverage/end-to-end verification called out as still pending + (Phase 2), rather than leaving the whole package described as "not + yet implemented" once the Phase 1 PR actually shipped it. Milestone + exit criteria still correctly not marked met — that still needs + Phase 2. - [x] `docs/public-api.md`: Provider Extensibility section now says "Implemented" rather than "design target" — done early, same PR #28 review round. NSubstitute Integration section correctly still says "not yet implemented." +- [x] `docs/public-api.md`: NSubstitute Integration section updated again + in the Phase 1 PR (Codex, P2) — now says "Implemented (PLAN-0005 + Phase 1)" rather than "not yet implemented," same reasoning as the + `docs/mvp.md` update above. - [x] `docs/public-api.md`: Naming Vocabulary gains `ICompositionValueProvider` — already added during the design phase. - [x] `docs/adr/README.md`/`docs/plans/README.md` index rows (already added @@ -317,6 +327,39 @@ alpha must: ## Notes +**Phase 1 (Done):** + +- Implemented exactly per ADR-0025's Amendment 1 corrected shapes — no + deviation from the ADR's own code sketches for `NSubstituteOptions`, + `NSubstituteProvider`, or `CompositionBuilderExtensions`. +- `Compono.NSubstitute.csproj` mirrors `Compono.XunitV3.csproj`'s shape: + `net10.0;net11.0` TFMs, `ProjectReference` to `Compono` with + `PrivateAssets="none"` (PLAN-0004 Phase 3's packaging lesson, applied + proactively rather than rediscovered), `PackageReference` to + `NSubstitute` (version centrally managed, already present in + `Directory.Packages.props` from the test-project usage), and + `InternalsVisibleTo` for the not-yet-created `Compono.NSubstitute.Tests` + (Phase 2) so `IsSubstitutable` can stay `internal`. + `docs/architecture.md`'s package-dependency diagram already lists + `Compono.NSubstitute → Compono, NSubstitute` — matched exactly, no + reference to any other package. +- Added to `Compono.slnx`. Whole-solution `dotnet build` green, no warnings. +- No test project yet (Phase 2) — `IsSubstitutable`/`NSubstituteProvider`/ + `UseNSubstitute` are implemented but only build-verified in this phase, + not test-verified; that's Phase 2's job per the plan's own phase split. +- **PR review (Codex, one P2 finding) caught the same doc-staleness pattern + PR #28's second review round caught for Phase 0.** `docs/public-api.md`'s + NSubstitute Integration section and `docs/mvp.md`'s Milestone 5 section + both still said `Compono.NSubstitute` was "not yet implemented" after + this phase's package/provider/`UseNSubstitute()` code had already landed + in the same PR — pulled forward and fixed in this PR rather than + deferred to Phase 3, on the same reasoning as the earlier round: leaving + the docs contradict the code until a later phase lands is worse than a + small out-of-phase doc correction. Both now say "implemented (PLAN-0005 + Phase 1)" and explicitly call out that test coverage/end-to-end + verification remain pending (Phase 2) — this milestone's exit criteria + still correctly not marked met. + **Phase 0 (Done):** - Implemented in the same branch/PR as the design docs, per explicit user diff --git a/docs/public-api.md b/docs/public-api.md index 18e66c7..0a336be 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -422,9 +422,11 @@ same interface, registered via `AddSemanticProvider` instead of ## NSubstitute Integration -Design target, resolved (not yet implemented) by +Implemented (PLAN-0005 Phase 1) per [ADR-0025](adr/0025-compono-nsubstitute-package-design.md), built on the Provider -Extensibility contract above. +Extensibility contract above — `Compono.NSubstitute`'s `NSubstituteProvider`/ +`NSubstituteOptions`/`UseNSubstitute()` are real code today. Test coverage and +end-to-end verification are still pending (PLAN-0005 Phase 2). Activation: diff --git a/src/Compono.NSubstitute/Compono.NSubstitute.csproj b/src/Compono.NSubstitute/Compono.NSubstitute.csproj new file mode 100644 index 0000000..f11047d --- /dev/null +++ b/src/Compono.NSubstitute/Compono.NSubstitute.csproj @@ -0,0 +1,23 @@ + + + + latest + enable + enable + net10.0;net11.0 + NSubstitute integration for Compono - a test-double provider composing interface, delegate, and (optionally) abstract-class parameters as real NSubstitute substitutes. See docs/adr/0024-public-provider-extensibility-model.md and docs/adr/0025-compono-nsubstitute-package-design.md. + + + + + + + + + + + + + diff --git a/src/Compono.NSubstitute/CompositionBuilderExtensions.cs b/src/Compono.NSubstitute/CompositionBuilderExtensions.cs new file mode 100644 index 0000000..2b7c566 --- /dev/null +++ b/src/Compono.NSubstitute/CompositionBuilderExtensions.cs @@ -0,0 +1,28 @@ +namespace Compono; + +/// +/// Activates on a . See +/// docs/adr/0025-compono-nsubstitute-package-design.md. +/// +public static class CompositionBuilderExtensions +{ + extension(CompositionBuilder builder) + { + /// + /// Registers an with default . + /// + public CompositionBuilder UseNSubstitute() => builder.UseNSubstitute(static _ => { }); + + /// + /// Registers an , configured by . + /// + /// Sets the provider's . + public CompositionBuilder UseNSubstitute(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var options = new NSubstituteOptions(); + configure(options); + return builder.AddTestDoubleProvider(new NSubstituteProvider(options)); + } + } +} diff --git a/src/Compono.NSubstitute/NSubstituteOptions.cs b/src/Compono.NSubstitute/NSubstituteOptions.cs new file mode 100644 index 0000000..11cde0d --- /dev/null +++ b/src/Compono.NSubstitute/NSubstituteOptions.cs @@ -0,0 +1,15 @@ +namespace Compono; + +/// +/// Configuration for , set via +/// CompositionBuilderExtensions.UseNSubstitute(Action{NSubstituteOptions}). See +/// docs/adr/0025-compono-nsubstitute-package-design.md. +/// +public sealed class NSubstituteOptions +{ + /// + /// Whether an unsealed abstract class is substitutable, in addition to every interface and + /// delegate type. Defaults to . + /// + public bool SubstituteAbstractClasses { get; set; } = true; +} diff --git a/src/Compono.NSubstitute/NSubstituteProvider.cs b/src/Compono.NSubstitute/NSubstituteProvider.cs new file mode 100644 index 0000000..eaf4c63 --- /dev/null +++ b/src/Compono.NSubstitute/NSubstituteProvider.cs @@ -0,0 +1,46 @@ +using NSubstitute; + +namespace Compono; + +/// +/// A stage-6 test-double provider that composes an interface, delegate, or (when +/// allows it) unsealed abstract-class +/// request as a real NSubstitute substitute, via . +/// Registered via CompositionBuilderExtensions.UseNSubstitute(). See +/// docs/adr/0025-compono-nsubstitute-package-design.md (Amendment 1 for the corrected shape +/// below). +/// +public sealed class NSubstituteProvider : ICompositionValueProvider +{ + private readonly bool _substituteAbstractClasses; + + /// + /// Creates an , snapshotting 's + /// current values - the provider never retains the caller-owned + /// instance itself, so a caller mutating it after construction can't change this + /// already-registered provider's behavior (ADR-0024's immutable-provider-registration guarantee). + /// + /// The options to snapshot. + public NSubstituteProvider(NSubstituteOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _substituteAbstractClasses = options.SubstituteAbstractClasses; + } + + /// + public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context) + { + if (!IsSubstitutable(request.RequestedType, _substituteAbstractClasses)) + return CompositionProviderResult.NotHandled; + + return CompositionProviderResult.Handled(Substitute.For([request.RequestedType], [])); + } + + // ADR-0025 Amendment 1: IsSubclassOf(typeof(MulticastDelegate)), not + // typeof(Delegate).IsAssignableFrom(requestedType) - the latter wrongly matches the + // non-substitutable Delegate/MulticastDelegate framework base types themselves. + internal static bool IsSubstitutable(Type requestedType, bool substituteAbstractClasses) => + requestedType.IsInterface + || requestedType.IsSubclassOf(typeof(MulticastDelegate)) + || (substituteAbstractClasses && requestedType.IsAbstract && !requestedType.IsSealed && !requestedType.IsInterface); +} diff --git a/src/Compono.XunitV3/Compono.XunitV3.csproj b/src/Compono.XunitV3/Compono.XunitV3.csproj index 699e03b..3fe7b91 100644 --- a/src/Compono.XunitV3/Compono.XunitV3.csproj +++ b/src/Compono.XunitV3/Compono.XunitV3.csproj @@ -9,17 +9,10 @@ - +