From 7a0c7746d95fd6f4a9d5eed606823b98b6ab9ecc Mon Sep 17 00:00:00 2001 From: Chris Winland <42553203+cwinland@users.noreply.github.com> Date: Mon, 4 May 2026 11:59:40 -0400 Subject: [PATCH 1/6] Document generated-test settings design for #162 --- docs/roadmap/README.md | 19 +- docs/roadmap/generated-test-settings.md | 348 ++++++++++++++++++++++++ docs/roadmap/generator-roadmap.md | 10 +- 3 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 docs/roadmap/generated-test-settings.md diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index e6d95602..27499d08 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -124,20 +124,22 @@ The main value is not "generated mocks" in isolation. The stronger FastMoq-speci Current public issue crosswalk: -- foundation and package-shape work: [#120](https://github.com/cwinland/FastMoq/issues/120), [#121](https://github.com/cwinland/FastMoq/issues/121), [#125](https://github.com/cwinland/FastMoq/issues/125), [#126](https://github.com/cwinland/FastMoq/issues/126), [#127](https://github.com/cwinland/FastMoq/issues/127), and [#134](https://github.com/cwinland/FastMoq/issues/134) +- foundation and package-shape work: [#120](https://github.com/cwinland/FastMoq/issues/120), [#121](https://github.com/cwinland/FastMoq/issues/121), [#125](https://github.com/cwinland/FastMoq/issues/125), [#126](https://github.com/cwinland/FastMoq/issues/126) for the stable `ScenarioBuilder` scaffolding contract, [#127](https://github.com/cwinland/FastMoq/issues/127), and [#134](https://github.com/cwinland/FastMoq/issues/134) - near-term helper preparation that can improve v4 authoring before generators ship: [#132](https://github.com/cwinland/FastMoq/issues/132), [#133](https://github.com/cwinland/FastMoq/issues/133), and [#135](https://github.com/cwinland/FastMoq/issues/135) -- first implementation-facing outcomes, in planned order: [#122](https://github.com/cwinland/FastMoq/issues/122), [#136](https://github.com/cwinland/FastMoq/issues/136), [#123](https://github.com/cwinland/FastMoq/issues/123), [#137](https://github.com/cwinland/FastMoq/issues/137), and [#124](https://github.com/cwinland/FastMoq/issues/124) -- later generated-test settings and test-platform targeting for those authoring flows: [#162](https://github.com/cwinland/FastMoq/issues/162) +- completed first implementation-facing MVP: [#122](https://github.com/cwinland/FastMoq/issues/122) +- current shared generated-test settings and test-platform contract gate for later authoring flows: [#162](https://github.com/cwinland/FastMoq/issues/162) +- later implementation-facing outcomes after the shared `#162` contract settles: [#136](https://github.com/cwinland/FastMoq/issues/136) for scenario and suite scaffolding, [#123](https://github.com/cwinland/FastMoq/issues/123), [#137](https://github.com/cwinland/FastMoq/issues/137), and [#124](https://github.com/cwinland/FastMoq/issues/124) - later evaluation tracks after the provider-first generator story is stable: [#138](https://github.com/cwinland/FastMoq/issues/138) and [#139](https://github.com/cwinland/FastMoq/issues/139) Planned direction stays phased: 1. Compile-time test graph and harness generation. -2. Scenario and suite scaffolding. -3. Full generated tests from existing supported classes. -4. Framework-helper builders for repeated helper-heavy test patterns. -5. Analyzer-guided generation flow and package suggestions. -6. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. +2. Shared generated-test settings and test-platform contract. +3. Scenario and suite scaffolding. +4. Full generated tests from existing supported classes. +5. Framework-helper builders for repeated helper-heavy test patterns. +6. Analyzer-guided generation flow and package suggestions. +7. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. Current package and MVP contract direction for [#120](https://github.com/cwinland/FastMoq/issues/120): @@ -179,6 +181,7 @@ Current implementation status for [#122](https://github.com/cwinland/FastMoq/iss - next: settle the shared generated-test settings and test-platform model in [#162](https://github.com/cwinland/FastMoq/issues/162) before widening into framework-specific scaffolds, full generated tests, or analyzer entry points For the current detailed direction, design constraints, and fuller generator issue mapping, see [Generator roadmap and design](./generator-roadmap.md). +For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). ### `MockOptional` retirement diff --git a/docs/roadmap/generated-test-settings.md b/docs/roadmap/generated-test-settings.md new file mode 100644 index 00000000..32f6fa71 --- /dev/null +++ b/docs/roadmap/generated-test-settings.md @@ -0,0 +1,348 @@ +# FastMoq Generated Test Settings Design + +This page captures the current `#162` design direction for generated-test authoring settings. It is design-level only. It does not imply shipped settings resolution, generator behavior changes, analyzer behavior changes, or external scaffolding support on the current branch. + +## Purpose And Non-Goals + +Purpose: + +- define one shared authoring-time settings contract for later generated scenario scaffolds, full generated tests, analyzer-guided entry points, and any helper-builder output that explicitly opts into the same contract +- keep `#123`, `#124`, `#126`, `#136`, and conditionally `#137` aligned to one vocabulary for naming, platform targeting, regeneration, and extensibility +- settle where those settings live, how they override one another, and which parts remain capability-gated by existing package-shape rules + +Non-goals: + +- no generator, analyzer, runtime, schema parser, or settings-loader implementation work in this slice +- no reopening of the `#122` harness MVP contract +- no reopening of the `#127` package-layout and target-shape authority +- no machine-local IDE-profile settings model +- no arbitrary user-authored template system, AST injection point, or plugin callback model in the first design + +## Current Baseline + +The current repo state that drives this design is: + +- `#122` is complete for the narrow harness MVP: explicit partial `MockerTestBase` targets can emit constructor-signature metadata and harness bootstrap, but the repo still does not emit full generated tests, scenario scaffolds, or framework-helper builders. +- `#127` is complete for package detection and target-shape rules: the analyzer layer already knows the supported generated target shapes for the referenced FastMoq package layout. +- `GeneratedHarnessSourceGenerator` currently hard-codes the target attribute metadata name, the `MockerTestBase` requirement, the generated metadata type name, the `ComponentConstructorParameterTypes` override, the auto-generated header, `#nullable enable`, and the `.FastMoq.GeneratedHarness.g.cs` hint-name suffix. +- `GeneratedTestTargetShapeRule` and `FastMoqAnalysisHelpers` currently own the package matrix, target-shape list, required package per shape, default base type name, and default namespaces for each supported generated test shape. +- `Directory.Packages.props` currently carries `xunit` `2.9.3` and `xunit.runner.visualstudio` `3.0.2`, while the repo documentation remains framework-agnostic. That mixed baseline is one reason syntax targeting and runner or bootstrap targeting must stay separate settings. +- `FastMoq.Generators.csproj` does not currently declare `CompilerVisibleProperty`, `CompilerVisibleItemMetadata`, or any equivalent custom bridge for generated-test authoring settings. + +## Consumer Map + +- `#126` consumes the shared settings contract for regeneration-safe scenario-scaffolding hooks and customization boundaries, but it still owns the `ScenarioBuilder` contract surface rather than the settings carrier. +- `#136` consumes the shared settings contract for concrete scenario and suite scaffolding implementation after `#126` settles the hook model. +- `#123` consumes the shared settings contract for naming, scaffold choice, framework syntax targeting, runner or bootstrap targeting, and regeneration behavior when widening from harness metadata into full generated tests. +- `#124` consumes the shared settings contract so analyzer-guided generation suggestions can reflect the same naming and platform choices without inventing analyzer-local defaults. +- `#137` is conditional: if helper-builder output needs shared naming, output, or regeneration rules, it should consume this contract later. This design does not assume that relationship unless a later implementation explicitly chooses it. + +## Settings Taxonomy + +### Package Acquisition And Capability Settings + +- Auto-add package policy. Classification: capability-gated user setting. First consumer: `#124` or a later external scaffolder. Notes: the in-compilation source generator must not mutate project files. +- Helper-family opt-in or opt-out. Classification: capability-gated user setting. First consumer: `#137` if it opts into the shared contract. Notes: the setting can only choose among helper families already supported by the `#127` package matrix. + +### Output Placement And Naming Settings + +- Destination project. Classification: deferred value placeholder. First consumer: a future external scaffolder, not the current in-compilation source generator. +- Destination folder. Classification: deferred value placeholder. First consumer: a future external scaffolder, not the current in-compilation source generator. +- Namespace template. Classification: true user setting. First consumer: `#123`, `#136`, and `#124`. +- Type and member naming templates. Classification: true user setting. First consumer: `#123`, `#136`, and conditionally `#137`. + +### Provider And Bootstrap Settings + +- Preferred provider. Classification: true user setting. First consumer: `#123` and `#136`. Notes: provider choice remains constrained by the FastMoq packages referenced by the consuming project. +- Provider bootstrap style. Classification: true user setting. First consumer: `#123` and `#136`. Notes: this covers how generated tests express or discover provider selection; it is separate from framework syntax or runner choice. + +### Test Syntax And Assertion Settings + +- Test framework syntax target. Classification: true user setting with explicit deferred values. First consumer: `#123`, `#136`, and `#124`. +- Runner or bootstrap target. Classification: true user setting with explicit deferred values. First consumer: `#123`, `#136`, and `#124`. +- Assertion style. Classification: true user setting. First consumer: `#123` and `#136`. Notes: the repo already uses more than one assertion style in documentation, so generated output should not silently hard-code one style forever. + +### Scaffold-Shape Settings + +- Preferred scaffold shape. Classification: true user setting. First consumer: `#123` and `#136`. +- Hook emission style. Classification: structured extensibility point. First consumer: `#126` and `#136`. +- Helper-builder generation preference. Classification: capability-gated user setting. First consumer: conditionally `#137`. + +### Regeneration-Safety Settings + +- Regeneration policy. Classification: true user setting. First consumer: `#123`, `#136`, and conditionally `#137`. +- Partial-extension boundary policy. Classification: structured extensibility point. First consumer: `#126` and `#136`. +- Hand-edited safe-zone policy. Classification: deferred value placeholder until a non-generator file-emission workflow exists. + +## Settings Carrier, Scope, And Precedence Model + +The first persisted carrier should be MSBuild-backed configuration authored at repo or project scope. The authoritative path is: + +1. built-in defaults derived from package matrix and target shape +2. repo defaults in `Directory.Build.props` +3. consuming project overrides in the project file +4. future explicit tool or code-action arguments for external scaffolding flows only + +The first-wave property family should use a single `FastMoqGeneratedTest...` prefix so the settings are easy to discover in MSBuild and easy to surface through analyzer-config global options. Recommended first-wave property names are: + +- `FastMoqGeneratedTestFramework` +- `FastMoqGeneratedTestRunner` +- `FastMoqGeneratedTestProvider` +- `FastMoqGeneratedTestProviderBootstrap` +- `FastMoqGeneratedTestAssertionStyle` +- `FastMoqGeneratedTestScaffold` +- `FastMoqGeneratedTestHookEmission` +- `FastMoqGeneratedTestRegenerationPolicy` +- `FastMoqGeneratedTestAutoAddPackages` +- `FastMoqGeneratedTestNamespace` +- `FastMoqGeneratedTestTypeNamePattern` +- `FastMoqGeneratedTestMethodNamePattern` +- `FastMoqGeneratedTestHelperFamilies` + +Reserved later properties for external scaffolding flows are: + +- `FastMoqGeneratedTestOutputProject` +- `FastMoqGeneratedTestOutputFolder` + +`.editorconfig` stays secondary in the first design. It can participate later for path-scoped analyzer behavior if a real need appears, but it should not replace repo/project-scoped MSBuild settings as the authoritative source for generated-test authoring choices. + +JSON or `AdditionalFiles` manifests are explicitly deferred. They should only be introduced if the structured settings shape demonstrably outgrows practical MSBuild properties or simple semicolon-delimited list values. + +Implementation note: although Roslyn analyzers and generators can observe `build_property.*` values through generated analyzer config, this repo does not currently expose any custom generated-test settings bridge in `FastMoq.Generators.csproj`. A future implementation slice must add the required compiler-visible-property or equivalent build plumbing before any custom settings can flow into analyzer or generator code. + +## Extensibility Model + +The first design should treat extensibility as constrained authoring control, not as an open plugin system. + +The surface breaks into three buckets: + +- Closed choices. Examples: framework syntax target, runner target, assertion style, provider bootstrap style, scaffold preference, regeneration policy. +- Structured override points. Examples: hook emission style, named partial companion hooks, and regeneration-safe setup or verify seams that map back to existing FastMoq runtime surfaces. +- Deferred open extensibility. Examples: arbitrary user-authored templates, AST injection, or plugin callbacks. These are intentionally out of scope for the first design. + +The first override mechanism should be companion partial hooks only. Do not introduce multiple parallel extension systems in the first design. + +The first `GeneratedHookEmissionStyle` choice set is: + +- `None` +- `CompanionPartialHooks` + +The first named hook roles should stay minimal and map back to existing runtime surfaces instead of inventing a second runtime model: + +- `ConfigureGeneratedMockerPolicy` for `MockerTestBase.ConfigureMockerPolicy` +- `ConfigureGeneratedMocks` for `MockerTestBase.SetupMocksAction` +- `SelectGeneratedConstructor` for `ComponentConstructorParameterTypes` and `CreateComponentConstructionRequest()`-based customization +- `AfterGeneratedComponentCreated` for `MockerTestBase.CreatedComponentAction` +- `ArrangeGeneratedScenario` for `ScenarioBuilder.With(...)` and `When(...)` +- `AssertGeneratedScenario` for `ScenarioBuilder.Then(...)`, `WhenThrows(...)`, and provider-neutral `Verify(...)` flows + +Custom overrides on test structure should come through scaffold selection plus those regeneration-safe named hooks, not through free-form template injection. + +## Supported And Deferred Framework And Runner Matrix + +Framework syntax targets: + +| Target | Status | Notes | +| --- | --- | --- | +| `XUnitV2` | Supported baseline | Matches the current repo package baseline most closely. | +| `XUnitV3` | Supported with differences | Syntax stays similar for many tests, but fixture and runner choices differ enough that it must remain explicit. | +| `NUnit` | Supported with differences | Attribute names, lifecycle hooks, and assertion guidance differ. | +| `MSTest` | Supported with differences | Attribute names and suite structure differ. | +| `Deferred` | Explicit placeholder | Use when the consuming flow cannot yet emit the requested framework cleanly. | + +Runner or bootstrap targets: + +| Target | Status | Notes | +| --- | --- | --- | +| `ProjectDefault` | Supported baseline | Uses the consuming test project's established runner/bootstrap story without trying to infer more than the project already declares. | +| `VSTest` | Supported | Runner choice remains separate from syntax choice. | +| `MTP` | Supported | Runner choice remains separate from syntax choice. | +| `ModuleInitializer` | Deferred | Bootstrap-specific flow that should stay explicit instead of inferred. | +| `AssemblyFixture` | Deferred | Framework-specific bootstrap behavior should remain explicit. | +| `Deferred` | Explicit placeholder | Use when the requested bootstrap shape is not yet implemented. | + +The design requirement behind both tables is simple: syntax target and runner or bootstrap mode are related, but they are not the same setting and should not be collapsed into one inferred value. + +## Candidate API And Design Outline + +The first descriptive model should use authoring-time names rather than runtime metadata names: + +```csharp +public sealed record GeneratedTestAuthoringSettings +{ + public GeneratedTestPackagePolicySettings PackagePolicy { get; init; } + public GeneratedTestPlacementSettings Placement { get; init; } + public GeneratedTestNamingTemplateSettings Naming { get; init; } + public GeneratedTestProviderSelectionSettings ProviderSelection { get; init; } + public GeneratedTestSyntaxTargetSettings SyntaxTarget { get; init; } + public GeneratedTestScaffoldPreferenceSettings ScaffoldPreference { get; init; } + public GeneratedTestRegenerationSettings Regeneration { get; init; } +} + +public sealed record GeneratedTestPackagePolicySettings +{ + public GeneratedTestPackagePolicy AutoAddPackages { get; init; } + public string[] HelperFamilies { get; init; } +} + +public sealed record GeneratedTestPlacementSettings +{ + public string? Namespace { get; init; } + public string? OutputProject { get; init; } + public string? OutputFolder { get; init; } +} + +public sealed record GeneratedTestNamingTemplateSettings +{ + public string TestTypeNamePattern { get; init; } + public string TestMethodNamePattern { get; init; } + public string ScenarioTypeNamePattern { get; init; } +} + +public sealed record GeneratedTestProviderSelectionSettings +{ + public string PreferredProvider { get; init; } + public GeneratedTestProviderBootstrapStyle BootstrapStyle { get; init; } +} + +public sealed record GeneratedTestSyntaxTargetSettings +{ + public GeneratedTestFrameworkSyntaxTarget Framework { get; init; } + public GeneratedTestRunnerBootstrapMode Runner { get; init; } + public string AssertionStyle { get; init; } +} + +public sealed record GeneratedTestScaffoldPreferenceSettings +{ + public GeneratedTestScaffoldKind PreferredScaffold { get; init; } + public GeneratedHookEmissionStyle HookEmission { get; init; } + public bool GenerateHelperBuildersWhenSupported { get; init; } +} + +public sealed record GeneratedTestRegenerationSettings +{ + public GeneratedTestRegenerationPolicy Policy { get; init; } + public bool PreserveCompanionPartials { get; init; } + public string[] SafeZones { get; init; } +} +``` + +Related enum and value-object families should stay separate for clarity: + +- `GeneratedTestFrameworkSyntaxTarget` +- `GeneratedTestRunnerBootstrapMode` +- `GeneratedTestProviderBootstrapStyle` +- `GeneratedTestScaffoldKind` +- `GeneratedHookEmissionStyle` +- `GeneratedTestPackagePolicy` +- `GeneratedTestRegenerationPolicy` + +Intended first-use classification by type: + +- `GeneratedTestAuthoringSettings`: top-level true user-setting aggregate; first carrier is MSBuild-backed analyzer-config values. +- `GeneratedTestPackagePolicySettings`: capability-gated user settings; primary consumer is analyzer-guided or external scaffolding flows. +- `GeneratedTestPlacementSettings`: mixed user setting plus deferred placeholders; `OutputProject` and `OutputFolder` are not first-class source-generator controls yet. +- `GeneratedTestNamingTemplateSettings`: true user settings; consumed by generated scenario scaffolds and full generated tests. +- `GeneratedTestProviderSelectionSettings`: true user settings; constrained by referenced FastMoq provider packages. +- `GeneratedTestSyntaxTargetSettings`: true user settings with explicit deferred values. +- `GeneratedTestScaffoldPreferenceSettings`: mixed user settings and structured extensibility point. +- `GeneratedTestRegenerationSettings`: mixed user settings and structured extensibility point. + +## Illustrative MSBuild Configuration Examples + +Repo-level defaults in `Directory.Build.props`: + +```xml + + + XUnitV2 + ProjectDefault + reflection + ProjectDefault + AwesomeAssertions + ScenarioScaffold + CompanionPartialHooks + PreserveCompanionPartials + SuggestOnly + $(RootNamespace).GeneratedTests + {ComponentName}GeneratedTests + {MemberName}_Should_{Outcome} + Web;Database + + +``` + +Project-level override in a consuming test project: + +```xml + + + NUnit + MTP + FullTestClass + $(RootNamespace).Generated + + +``` + +These examples are illustrative only. They document the intended first carrier and naming family, not working current implementation. + +## Regeneration And Safe-Zone Rules + +- Generated `.g.cs` output remains generator-owned and should be fully replaceable. +- User customization should live in companion partials or named hook seams, not in edited generated output. +- `PreserveCompanionPartials` should be the default regeneration stance when a file-emitting flow exists. +- When a requested scaffold shape cannot provide a safe regeneration seam, the implementation should fail clearly or document the unsupported case instead of overwriting user edits silently. +- Destination project and destination folder are future external-scaffolder concerns. They do not become meaningful first-class controls for the in-compilation source generator by themselves. +- Hand-edited safe zones remain a deferred concept until a non-generator file-emission workflow exists. + +## Package-Aware Gating Rules + +Settings may choose among supported outputs, but settings must not override the package-shape authority already owned by `#127`. + +That means: + +- `FastMoqAnalysisHelpers.GetGeneratedTestPackageMatrix(...)` remains the authoritative package-layout summary. +- `FastMoqAnalysisHelpers.SupportsGeneratedTestTargetShape(...)` remains the authoritative check for whether a requested generated target shape is valid for the current project. +- `ResolveGeneratedTestPackageLayout(...)` and `BuildSupportedGeneratedTestTargetShapes(...)` remain analyzer-owned capability logic. +- helper-family settings can only request helper output that is already valid for the referenced FastMoq package layout. +- naming, scaffold, provider, or runner settings must never bypass unsupported target-shape or helper-package combinations. + +## Settings Versus Invariants + +The following items are explicitly not user settings in `#162`. + +| Item | Classification | Rationale | +| --- | --- | --- | +| `FastMoq.Generators.FastMoqGeneratedTestTargetAttribute` metadata name | Not a setting | This is the current generator target identity, not an authoring preference. | +| Explicit partial-class requirement and `MockerTestBase` target eligibility | Not a setting | These are current MVP target-shape rules from `#122`. | +| `FastMoqGeneratedHarnessMetadata` type name | Not a setting | Generated metadata naming is part of the current harness contract. | +| `ComponentConstructorParameterTypes` hook name | Not a setting | This is the existing runtime and generated harness hook from `MockerTestBase`. | +| `.FastMoq.GeneratedHarness.g.cs` hint-name suffix | Not a setting | File hint naming is generator-owned implementation detail today. | +| Current `// ` header and `#nullable enable` boilerplate | Not a setting | Generator-owned implementation detail today. | +| `FastMoqGeneratedTestPackageLayout` enum values | Not a setting | Analyzer-owned capability boundary from `#127`. | +| `GeneratedTestTargetShape` enum values | Not a setting | Analyzer-owned target-shape boundary from `#127`. | +| Default required package per target shape | Not a setting | Capability mapping remains analyzer-owned. | +| Default base-type and namespace lists per target shape | Not a setting | Capability mapping remains analyzer-owned. | + +The following items are candidate future settings or implementation concerns rather than settled invariants: + +| Item | Classification | Rationale | +| --- | --- | --- | +| Naming templates | Candidate future setting | Authoring-time concern consumed by later scaffold and full-test flows. | +| Framework syntax target | Candidate future setting | Authoring-time concern, distinct from runner/bootstrap. | +| Runner/bootstrap mode | Candidate future setting | Authoring-time concern, distinct from syntax. | +| Provider bootstrap style | Candidate future setting | Authoring-time concern for generated output shape. | +| Compiler-visible settings bridge | Future implementation concern | Required before custom settings can flow into Roslyn code, but not itself a user setting. | + +This section exists to keep `#162` from silently reopening `#122` or `#127`. + +## Explicit Follow-On Boundaries + +- `#162` ends once the shared settings contract, carrier model, precedence rules, support matrix, extensibility model, and invariants are documented and mirrored into the roadmap. +- `#126` should then focus only on regeneration-safe scenario/scaffold hooks that consume this contract. +- `#136` should implement scenario and suite scaffolding against the `#126` hook contract plus the `#162` settings contract. +- `#123` should implement full generated tests against this contract without inventing local defaults for framework syntax, runner/bootstrap mode, naming, or regeneration behavior. +- `#124` should route analyzer suggestions into already-supported generation layers while using this contract for defaults and choices. +- `#137` should only adopt this contract if helper-builder output actually needs the same naming, placement, regeneration, or framework-targeting choices. +- provider-optimized generation evaluation in `#138` and narrower fake-generation evaluation in `#139` remain intentionally later. diff --git a/docs/roadmap/generator-roadmap.md b/docs/roadmap/generator-roadmap.md index 62adbf48..5815deb2 100644 --- a/docs/roadmap/generator-roadmap.md +++ b/docs/roadmap/generator-roadmap.md @@ -4,6 +4,8 @@ This page captures the current v5 direction for FastMoq code generation. It is t This page is intentionally design-level only. It is appropriate for roadmap and implementation planning ahead of code, but it does not imply current shipped support. +For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). + FastMoq now contains a narrow first Roslyn source-generator slice for explicit `MockerTestBase` harness targets. The repo still does not emit full generated tests, scenario scaffolds, or broader framework-helper builders. ## Current Baseline @@ -30,11 +32,11 @@ The current public backlog for this design is: - [#127](https://github.com/cwinland/FastMoq/issues/127) package-detection and target-test-shape rules for generated tests - [#134](https://github.com/cwinland/FastMoq/issues/134) blocking helper-surface normalization for logging, HTTP, Azure, Azure Functions, and typed DI - [#122](https://github.com/cwinland/FastMoq/issues/122) compile-time graph and harness MVP +- [#162](https://github.com/cwinland/FastMoq/issues/162) generated-test settings and test-platform targeting as the current gate before wider authoring flows - [#136](https://github.com/cwinland/FastMoq/issues/136) generated scenario and suite scaffolding after the graph and harness MVP - [#137](https://github.com/cwinland/FastMoq/issues/137) generator-backed framework-helper builders for repeated test patterns - [#123](https://github.com/cwinland/FastMoq/issues/123) full provider-first test generation from existing services and supported classes - [#124](https://github.com/cwinland/FastMoq/issues/124) analyzer-guided test generation and missing-package suggestions -- [#162](https://github.com/cwinland/FastMoq/issues/162) generated-test settings and test-platform targeting for later generation flows - [#138](https://github.com/cwinland/FastMoq/issues/138) later provider-optimized generation evaluation - [#139](https://github.com/cwinland/FastMoq/issues/139) later narrow compile-time fake or mock generation evaluation @@ -44,8 +46,10 @@ Crosswalk summary: - `#132`, `#133`, and `#135` are the narrower v4-style quick wins that are now implemented on the current milestone branch. - `#146` and `#147` carry the near-term analyzer follow-up for those landed helper surfaces. - `#120`, `#125`, `#126`, `#127`, and `#134` are the pre-v5 contract and blocking prerequisite slices. -- `#122`, `#136`, `#137`, `#123`, and `#124` are the phased implementation and authoring-flow outcomes once the prerequisites are stable enough. -- `#162` is the later settings and test-platform contract for generated scenario scaffolds, full generated tests, and analyzer entry points. +- `#122` is the completed first implementation-facing MVP for compile-time graph metadata and harness bootstrap. +- `#162` is the current shared settings and test-platform contract gate before wider generated scenario scaffolds, full generated tests, and analyzer entry points. +- `#126` is the stable scenario-scaffolding contract layer, and `#136` is the implementation slice that follows it. +- `#137`, `#123`, and `#124` remain the later phased implementation and authoring-flow outcomes once the current `#162` contract is settled. - `#138` and `#139` are intentionally late evaluation tracks after the main provider-first generator story is already working. ## Product Positioning From 1a91be511551bc90a6b291f5e2ede53be98ee0b3 Mon Sep 17 00:00:00 2001 From: Chris Winland <42553203+cwinland@users.noreply.github.com> Date: Mon, 4 May 2026 12:46:52 -0400 Subject: [PATCH 2/6] Fix generated harness parameterless constructor selection --- .../FastMoqGeneratedTestTargetAttribute.cs | 14 +++++- .../GeneratedHarnessSourceGeneratorTests.cs | 45 ++++++++++++++++++- .../GeneratedHarnessSourceGenerator.cs | 16 ++++--- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/FastMoq.Abstractions/Generators/FastMoqGeneratedTestTargetAttribute.cs b/FastMoq.Abstractions/Generators/FastMoqGeneratedTestTargetAttribute.cs index 0cfe7488..260a483d 100644 --- a/FastMoq.Abstractions/Generators/FastMoqGeneratedTestTargetAttribute.cs +++ b/FastMoq.Abstractions/Generators/FastMoqGeneratedTestTargetAttribute.cs @@ -14,10 +14,20 @@ public sealed class FastMoqGeneratedTestTargetAttribute : Attribute /// Initializes a new instance of the class. /// /// The component under test that the generated harness path should target. - /// An optional explicit constructor signature to use for the generated harness bootstrap. - public FastMoqGeneratedTestTargetAttribute(Type componentType, params Type[] constructorParameterTypes) + public FastMoqGeneratedTestTargetAttribute(Type componentType) { ComponentType = componentType ?? throw new ArgumentNullException(nameof(componentType)); + ConstructorParameterTypes = Array.Empty(); + } + + /// + /// Initializes a new instance of the class with an explicit constructor signature. + /// + /// The component under test that the generated harness path should target. + /// The explicit constructor signature to use for the generated harness bootstrap. Pass an explicit empty array to target the parameterless constructor. + public FastMoqGeneratedTestTargetAttribute(Type componentType, params Type[] constructorParameterTypes) + : this(componentType) + { ConstructorParameterTypes = constructorParameterTypes ?? throw new ArgumentNullException(nameof(constructorParameterTypes)); } diff --git a/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs b/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs index 48c21065..e0a9bf55 100644 --- a/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs +++ b/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs @@ -144,7 +144,50 @@ public partial class OrderSubmitterTests : MockerTestBase var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); generatedSource.Should().Contain("typeof(global::Demo.Tests.IOrderGateway)"); - generatedSource.Should().NotContain("new global::System.Type[]\r\n {\r\n };"); + generatedSource.Should().NotContain("new global::System.Type?[]\r\n {\r\n };"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldUseExplicitParameterlessConstructor_ForMultiConstructorTarget() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; +using System; + +namespace Demo.Tests; + +public interface IOrderGateway { } + +public sealed class OrderSubmitter +{ + public OrderSubmitter() + { + ConstructorKind = ""parameterless""; + } + + public OrderSubmitter(IOrderGateway gateway) + { + ConstructorKind = ""dependency""; + } + + public string ConstructorKind { get; } +} + +[FastMoqGeneratedTestTarget(typeof(OrderSubmitter), new global::System.Type[] { })] +public partial class OrderSubmitterTests : MockerTestBase +{ + public string DescribeConstructorKind() => Component.ConstructorKind; + + public Type?[]? DescribeConstructorTypes() => ComponentConstructorParameterTypes; +} +"; + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.OrderSubmitterTests"); + + Invoke(generatedHarness, "DescribeConstructorKind").Should().Be("parameterless"); + Invoke(generatedHarness, "DescribeConstructorTypes").Should().BeEmpty(); } [Fact] diff --git a/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs b/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs index da210c26..a6a026ad 100644 --- a/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs +++ b/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs @@ -96,10 +96,14 @@ attribute.ConstructorArguments[0].Value is not ITypeSymbol attributeComponentTyp private static ImmutableArray GetExplicitConstructorParameterTypes(AttributeData attribute) { - if (attribute.ConstructorArguments.Length < 2 || - attribute.ConstructorArguments[1].Kind != TypedConstantKind.Array) + if (attribute.ConstructorArguments.Length < 2) { - return ImmutableArray.Empty; + return default; + } + + if (attribute.ConstructorArguments[1].Kind != TypedConstantKind.Array) + { + return default; } var builder = ImmutableArray.CreateBuilder(); @@ -123,7 +127,7 @@ private static bool TryResolveConstructor( .Where(static constructor => !constructor.IsStatic) .ToImmutableArray(); - if (!explicitConstructorParameterTypes.IsDefaultOrEmpty) + if (!explicitConstructorParameterTypes.IsDefault) { selectedConstructor = instanceConstructors.FirstOrDefault(constructor => ParametersMatch(constructor, explicitConstructorParameterTypes)); @@ -183,7 +187,7 @@ private static void EmitSource(SourceProductionContext context, GeneratedHarness AppendIndentedLine(sourceBuilder, 2, "internal static class FastMoqGeneratedHarnessMetadata"); AppendIndentedLine(sourceBuilder, 2, "{"); AppendIndentedLine(sourceBuilder, 3, $"internal static global::System.Type ComponentType {{ get; }} = typeof({target.ComponentTypeName});"); - AppendIndentedLine(sourceBuilder, 3, "internal static global::System.Type[] ConstructorParameterTypes { get; } = new global::System.Type[]"); + AppendIndentedLine(sourceBuilder, 3, "internal static global::System.Type?[] ConstructorParameterTypes { get; } = new global::System.Type?[]"); AppendIndentedLine(sourceBuilder, 3, "{"); foreach (var parameterTypeName in target.ConstructorParameterTypeNames) { @@ -199,7 +203,7 @@ private static void EmitSource(SourceProductionContext context, GeneratedHarness } AppendIndentedLine(sourceBuilder, 3, "};"); sourceBuilder.AppendLine(); - AppendIndentedLine(sourceBuilder, 3, "internal static global::System.Type[] DependencyTypes => ConstructorParameterTypes;"); + AppendIndentedLine(sourceBuilder, 3, "internal static global::System.Type?[] DependencyTypes => ConstructorParameterTypes;"); AppendIndentedLine(sourceBuilder, 2, "}"); AppendIndentedLine(sourceBuilder, 1, "}"); From a3f590f5f208b6a948e39cfab57d3e5667b443a8 Mon Sep 17 00:00:00 2001 From: Chris Winland <42553203+cwinland@users.noreply.github.com> Date: Mon, 4 May 2026 14:29:08 -0400 Subject: [PATCH 3/6] Define #126 scenario scaffolding contract --- docs/roadmap/README.md | 19 ++- ...generated-scenario-scaffolding-contract.md | 140 ++++++++++++++++++ docs/roadmap/generator-roadmap.md | 12 +- 3 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 docs/roadmap/generated-scenario-scaffolding-contract.md diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 27499d08..dd0d683e 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -122,24 +122,28 @@ FastMoq now contains a narrow first-party source-generator slice for explicit ha The main value is not "generated mocks" in isolation. The stronger FastMoq-specific opportunity is compile-time provider-first test generation: generated test graphs, harness scaffolding, and framework-helper builders that reduce reflection, reduce boilerplate, and stay aligned with FastMoq-owned APIs. +For the next scenario-contract design slice behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). + Current public issue crosswalk: - foundation and package-shape work: [#120](https://github.com/cwinland/FastMoq/issues/120), [#121](https://github.com/cwinland/FastMoq/issues/121), [#125](https://github.com/cwinland/FastMoq/issues/125), [#126](https://github.com/cwinland/FastMoq/issues/126) for the stable `ScenarioBuilder` scaffolding contract, [#127](https://github.com/cwinland/FastMoq/issues/127), and [#134](https://github.com/cwinland/FastMoq/issues/134) - near-term helper preparation that can improve v4 authoring before generators ship: [#132](https://github.com/cwinland/FastMoq/issues/132), [#133](https://github.com/cwinland/FastMoq/issues/133), and [#135](https://github.com/cwinland/FastMoq/issues/135) - completed first implementation-facing MVP: [#122](https://github.com/cwinland/FastMoq/issues/122) - current shared generated-test settings and test-platform contract gate for later authoring flows: [#162](https://github.com/cwinland/FastMoq/issues/162) -- later implementation-facing outcomes after the shared `#162` contract settles: [#136](https://github.com/cwinland/FastMoq/issues/136) for scenario and suite scaffolding, [#123](https://github.com/cwinland/FastMoq/issues/123), [#137](https://github.com/cwinland/FastMoq/issues/137), and [#124](https://github.com/cwinland/FastMoq/issues/124) +- immediate next-step sequence after the shared `#162` contract: [#126](https://github.com/cwinland/FastMoq/issues/126) for the stable `ScenarioBuilder` scaffolding contract, then [#134](https://github.com/cwinland/FastMoq/issues/134) for helper-surface narrowing and re-triage, then [#136](https://github.com/cwinland/FastMoq/issues/136) for scenario and suite scaffolding implementation +- later implementation-facing outcomes once the contract and scaffold layers are stable: [#123](https://github.com/cwinland/FastMoq/issues/123), [#124](https://github.com/cwinland/FastMoq/issues/124), and later or conditional helper-builder work in [#137](https://github.com/cwinland/FastMoq/issues/137) - later evaluation tracks after the provider-first generator story is stable: [#138](https://github.com/cwinland/FastMoq/issues/138) and [#139](https://github.com/cwinland/FastMoq/issues/139) Planned direction stays phased: 1. Compile-time test graph and harness generation. 2. Shared generated-test settings and test-platform contract. -3. Scenario and suite scaffolding. -4. Full generated tests from existing supported classes. -5. Framework-helper builders for repeated helper-heavy test patterns. -6. Analyzer-guided generation flow and package suggestions. -7. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. +3. Stable `ScenarioBuilder` scaffolding contract. +4. Helper-surface narrowing and re-triage for generator-facing helper families. +5. Scenario and suite scaffolding implementation. +6. Full generated tests and analyzer-guided generation once the contract and scaffold layers are stable. +7. Framework-helper builders for repeated helper-heavy test patterns. +8. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. Current package and MVP contract direction for [#120](https://github.com/cwinland/FastMoq/issues/120): @@ -178,10 +182,11 @@ Current implementation status for [#122](https://github.com/cwinland/FastMoq/iss - done: representative generated consuming scenarios now compile against real generated harness output, and parity tests now prove the generated path matches the supported runtime harness path for the same component shapes - done: measured setup-path evidence is now recorded in [generated harness setup benchmark results](../benchmarks/results/generated-harness-setup-net8.md), where the generated bootstrap-descriptor path holds a slight edge over the runtime fallback path with effectively identical allocations on the richer single-constructor benchmark - done: the current branch now completes the planned `#122` MVP slice; later generated-test settings and framework or runner targeting move to [#162](https://github.com/cwinland/FastMoq/issues/162) while later scaffolding and full-test generation stay in [#136](https://github.com/cwinland/FastMoq/issues/136), [#123](https://github.com/cwinland/FastMoq/issues/123), and [#124](https://github.com/cwinland/FastMoq/issues/124) -- next: settle the shared generated-test settings and test-platform model in [#162](https://github.com/cwinland/FastMoq/issues/162) before widening into framework-specific scaffolds, full generated tests, or analyzer entry points +- next: settle the shared generated-test settings and test-platform model in [#162](https://github.com/cwinland/FastMoq/issues/162), then define the stable scenario-scaffolding contract in [#126](https://github.com/cwinland/FastMoq/issues/126), narrow helper-family follow-up in [#134](https://github.com/cwinland/FastMoq/issues/134), and only then widen into [#136](https://github.com/cwinland/FastMoq/issues/136), [#123](https://github.com/cwinland/FastMoq/issues/123), and [#124](https://github.com/cwinland/FastMoq/issues/124) For the current detailed direction, design constraints, and fuller generator issue mapping, see [Generator roadmap and design](./generator-roadmap.md). For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). +For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). ### `MockOptional` retirement diff --git a/docs/roadmap/generated-scenario-scaffolding-contract.md b/docs/roadmap/generated-scenario-scaffolding-contract.md new file mode 100644 index 00000000..dbdd0870 --- /dev/null +++ b/docs/roadmap/generated-scenario-scaffolding-contract.md @@ -0,0 +1,140 @@ +# Generated Scenario Scaffolding Contract + +This page captures the current design contract for generator-stable scenario and suite scaffolding in FastMoq. It is the canonical repo-local design artifact for [#126](https://github.com/cwinland/FastMoq/issues/126), not a shipped feature guide. + +This page is intentionally docs and API-design only. It does not imply that FastMoq currently emits generated scenario scaffolds. + +For the shared generated-test settings and extensibility contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). + +## Purpose And Non-Goals + +This slice exists to define the stable runtime contract that later generated scenario scaffolds should target. + +The goals are: + +- map generated scenario phases to existing FastMoq-owned runtime APIs +- define the first regeneration-safe customization seams for generated scenario output +- keep generated scenario flow provider-first and centered on existing FastMoq surfaces +- preserve the settled ownership boundaries from [#125](https://github.com/cwinland/FastMoq/issues/125), [#122](https://github.com/cwinland/FastMoq/issues/122), and [#162](https://github.com/cwinland/FastMoq/issues/162) + +This slice does not: + +- implement source generation for scenario or suite scaffolds +- redesign `ScenarioBuilder` into a separate scenario runtime model +- reopen constructor-selection, graph metadata, or harness bootstrap contracts already settled in `#125` and `#122` +- settle helper-family normalization for logging, HTTP, Azure, Azure Functions, or typed DI beyond the boundaries needed to keep the scenario contract narrow +- replace the settings, hook-emission-style, or regeneration-policy ownership already documented in `#162` + +## Current Runtime Anchors + +The first scenario-scaffolding contract should target the existing public FastMoq runtime surface instead of inventing a second scenario abstraction. + +Primary anchors: + +- `ScenarioBuilder.With(...)` for arrange steps +- `ScenarioBuilder.When(...)` for normal act steps +- `ScenarioBuilder.WhenThrows(...)` for expected-exception act steps that still allow trailing assertions +- `ScenarioBuilder.Then(...)` for assertion steps +- `ScenarioBuilder.Verify(...)` and `VerifyNoOtherCalls()` for provider-neutral verification steps +- `ScenarioBuilder.Execute()`, `ExecuteAsync()`, `ExecuteThrows()`, and `ExecuteThrowsAsync()` for execution semantics +- `MockerTestBase.Scenario` as the default scenario builder entry point for generated test-base scaffolds +- `MockerTestBase.SetupMocksAction`, `CreateComponentAction`, `CreatedComponentAction`, and `ConfigureMockerPolicy` as the surrounding component and mocker composition seams +- `MockerTestBase.ComponentConstructorParameterTypes`, `CreateComponentConstructionRequest()`, `GetComponentConstructionPlan()`, and `GetComponentHarnessBootstrapDescriptor()` as the settled harness and construction metadata hooks beneath the scenario layer +- `Mocks.VerifyLogged(...)`, `VerifyLoggedOnce(...)`, and `VerifyNotLogged(...)` as the existing provider-neutral logging verification surface when generated scaffolds need log assertions + +## First Supported Scaffold Shape + +The first supported scaffold shape should stay narrow and method-centric: + +- generated scenario methods live inside generated partial `MockerTestBase`-based test types +- generated scenario methods build on the existing `Scenario` property or `Mocks.Scenario(Component)` flow instead of creating a new standalone scenario-class abstraction +- generated scenario methods may consult `GetComponentConstructionPlan()` or `GetComponentHarnessBootstrapDescriptor()` when constructor or bootstrap metadata needs to shape the scaffold, but the scenario contract itself stays above the graph and harness layer + +This keeps the first contract aligned with the current runtime model and avoids creating a second compatibility-heavy scenario surface beside `ScenarioBuilder`. + +## Phase-To-API Mapping + +| Scaffold phase | Primary runtime APIs | Contract stance | +| --- | --- | --- | +| Arrange | `With(...)`, `SetupMocksAction`, `CreatedComponentAction`, `ConfigureMockerPolicy` | Generated scaffolds may use `With(...)` for scenario-local arrangement and existing test-base hooks for component-wide setup. | +| Act | `When(...)` | The normal action path for generated scenario methods. | +| Expected exception | `WhenThrows(...)`, `ExecuteThrows()`, `ExecuteThrowsAsync()` | The contract must preserve both continued-assertion and exception-object assertion flows. | +| Assert | `Then(...)` | Generated assertions should stay inside the normal scenario assert pipeline. | +| Verify | `Verify(...)`, `VerifyNoOtherCalls()`, `Mocks.VerifyLogged(...)` | Provider-neutral verification stays first-class. Logging stays on the existing mocker helper surface unless a later concrete generator need proves otherwise. | +| Execute | `Execute()`, `ExecuteAsync()` | Sync versus async execution remains a consumer choice shaped by `#162` settings and later scaffold implementation, not by local ad hoc defaults. | + +## Regeneration-Safe Customization Model + +The scenario contract must match the first extensibility direction already settled in `#162`: + +- generated source remains generator-owned and replaceable +- user customization belongs in companion partials or explicit generated hook seams, not in edited generated output +- the first hook-emission model stays constrained to `GeneratedHookEmissionStyle.None` and `GeneratedHookEmissionStyle.CompanionPartialHooks` +- `#126` defines the scenario hook roles that later scaffolds need; `#136` chooses the concrete emitted members that realize those roles while preserving regeneration safety + +The first minimal hook-role set should stay explicit: + +- `Arrange` +- `Act` +- `ExpectedException` +- `Assert` +- `Verify` + +These are contract roles, not a second runtime API. The later generated scaffold implementation may materialize them as companion-partial methods, generated delegates, or similarly narrow named seams, but it must not collapse them into one opaque user-editable block. + +## Verification And Logging Stance + +The first in-band verification path for generated scenarios is provider-neutral FastMoq verification: + +- use `ScenarioBuilder.Verify(...)` for explicit interaction assertions +- use `ScenarioBuilder.VerifyNoOtherCalls()` for verification closure where appropriate +- prefer `Mocks.VerifyLogged(...)` and related helpers for provider-neutral logging assertions instead of adding logging-specific methods to `ScenarioBuilder` in this design slice + +That means generated scenarios stay provider-first by default while still allowing hand-written custom hooks to use narrower helper or provider-specific behavior where a consuming suite intentionally opts into that path. + +## Async And Expected-Exception Semantics + +The current scenario contract already supports mixed sync and async phases. The generated scenario contract should preserve that behavior rather than redefining it. + +Key rules: + +- arrange, act, and assert phases can each be synchronous or asynchronous through the current `ScenarioBuilder` overload set +- `WhenThrows(...)` is the expected-exception path when the act phase should fail but trailing assertions should still run +- `ExecuteThrows()` and `ExecuteThrowsAsync()` are the path when the exception object itself is the main assertion target +- sync versus async generated test-method syntax belongs to `#162` settings consumption and `#136` scaffold implementation, not to a separate `#126` runtime model + +## Composition With Existing Contracts + +`#126` depends on lower layers that are already settled and must not reopen them. + +- `#125` remains the constructor-selection and public construction-planning contract boundary. +- `#122` remains the graph metadata and harness bootstrap implementation boundary. +- `#162` remains the shared settings, hook-emission-style, and regeneration-policy boundary. + +In practice that means: + +- generated scenarios may consume harness metadata, but they do not redefine constructor signatures, parameter-source categories, or graph shape +- generated scenarios must consume the shared `#162` settings vocabulary for hook emission, scaffold choice, framework syntax, runner/bootstrap targeting, naming, and regeneration behavior instead of inventing local defaults +- the scenario contract should remain stable even if later scaffold implementations support more than one framework syntax target or runner mode + +## Explicit Deferrals + +The following work stays outside `#126` even when it is closely related: + +- helper-family normalization across logging, HTTP, Azure, Azure Functions, and typed DI stays in [#134](https://github.com/cwinland/FastMoq/issues/134) +- concrete generated scenario and suite scaffolding implementation stays in [#136](https://github.com/cwinland/FastMoq/issues/136) +- full generated tests from existing services and supported classes stays in [#123](https://github.com/cwinland/FastMoq/issues/123) +- analyzer-guided generation routing and missing-package suggestions stays in [#124](https://github.com/cwinland/FastMoq/issues/124) +- helper-builder generation stays in [#137](https://github.com/cwinland/FastMoq/issues/137) + +If later discovery shows that one truly missing runtime seam blocks the contract, track that seam narrowly instead of silently widening `#126` into a broad implementation branch. + +## Follow-On Boundaries + +The immediate sequence after `#162` should stay explicit: + +- `#126` is the next design slice and defines the stable scenario-scaffolding contract +- `#134` is the next narrowing and re-triage pass for helper-family normalization that the scenario implementation may later depend on +- `#136` is the next implementation-facing PR once the scenario contract and helper boundaries are explicit +- `#123` and `#124` follow only after the contract and scaffold layers are stable enough to consume instead of re-deriving local defaults +- `#137` remains later and conditional rather than part of the immediate next-step chain diff --git a/docs/roadmap/generator-roadmap.md b/docs/roadmap/generator-roadmap.md index 5815deb2..da0d6b70 100644 --- a/docs/roadmap/generator-roadmap.md +++ b/docs/roadmap/generator-roadmap.md @@ -5,6 +5,7 @@ This page captures the current v5 direction for FastMoq code generation. It is t This page is intentionally design-level only. It is appropriate for roadmap and implementation planning ahead of code, but it does not imply current shipped support. For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). +For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). FastMoq now contains a narrow first Roslyn source-generator slice for explicit `MockerTestBase` harness targets. The repo still does not emit full generated tests, scenario scaffolds, or broader framework-helper builders. @@ -48,8 +49,11 @@ Crosswalk summary: - `#120`, `#125`, `#126`, `#127`, and `#134` are the pre-v5 contract and blocking prerequisite slices. - `#122` is the completed first implementation-facing MVP for compile-time graph metadata and harness bootstrap. - `#162` is the current shared settings and test-platform contract gate before wider generated scenario scaffolds, full generated tests, and analyzer entry points. -- `#126` is the stable scenario-scaffolding contract layer, and `#136` is the implementation slice that follows it. -- `#137`, `#123`, and `#124` remain the later phased implementation and authoring-flow outcomes once the current `#162` contract is settled. +- `#126` is the immediate next design slice after `#162` and defines the stable scenario-scaffolding contract layer. +- `#134` is the next narrowing and re-triage pass for helper-family normalization after `#126`. +- `#136` is the next implementation slice once the scenario contract and helper boundaries are explicit. +- `#123` and `#124` remain later phased implementation and authoring-flow outcomes after the contract and scaffold layers are stable. +- `#137` remains later and conditional rather than part of the immediate next-step chain. - `#138` and `#139` are intentionally late evaluation tracks after the main provider-first generator story is already working. ## Product Positioning @@ -239,7 +243,7 @@ Completed on the current milestone branch: - expanded provider-first verification helpers where a shared abstraction is still clear and stable - small helper-surface cleanups for logging, HTTP, or typed DI setup where the FastMoq-owned runtime surface already exists and only needed a more generator-friendly shape -These landed early because they improve normal authoring even before source generators ship. The remaining near-term follow-up is narrower analyzer guidance in [#146](https://github.com/cwinland/FastMoq/issues/146) and [#147](https://github.com/cwinland/FastMoq/issues/147), plus broader helper normalization in [#134](https://github.com/cwinland/FastMoq/issues/134). +These landed early because they improve normal authoring even before source generators ship. The remaining near-term follow-up is narrower analyzer guidance in [#146](https://github.com/cwinland/FastMoq/issues/146) and [#147](https://github.com/cwinland/FastMoq/issues/147), plus the narrower helper-family re-triage and normalization pass in [#134](https://github.com/cwinland/FastMoq/issues/134) after the `#126` scenario contract is explicit. ### Likely v5 blocking prerequisites @@ -407,7 +411,7 @@ Preferred post-`#122` decision: - keep the public planning API unchanged - use [#162](https://github.com/cwinland/FastMoq/issues/162) to settle generated-test settings and framework or runner targeting before widening into scaffolds or full tests - only enrich the internal graph model further if a later generation layer proves that more dependency-order metadata is actually required for compilation or parity -- move the next implementation-facing generator work into [#136](https://github.com/cwinland/FastMoq/issues/136), [#123](https://github.com/cwinland/FastMoq/issues/123), and [#124](https://github.com/cwinland/FastMoq/issues/124) once that settings model is explicit +- move next into the scenario-contract slice in [#126](https://github.com/cwinland/FastMoq/issues/126), then the helper-family narrowing pass in [#134](https://github.com/cwinland/FastMoq/issues/134), then the next implementation-facing generator work in [#136](https://github.com/cwinland/FastMoq/issues/136), with [#123](https://github.com/cwinland/FastMoq/issues/123) and [#124](https://github.com/cwinland/FastMoq/issues/124) following only after the contract and scaffold layers are stable ## Suggested v5 Delivery Phases From 85144818e71e11812e132698c7421245f7e4aa7c Mon Sep 17 00:00:00 2001 From: Chris Winland <42553203+cwinland@users.noreply.github.com> Date: Mon, 4 May 2026 15:01:41 -0400 Subject: [PATCH 4/6] Document #134 helper-family narrowing matrix --- docs/roadmap/README.md | 67 ++--------- .../roadmap/generated-helper-family-matrix.md | 107 ++++++++++++++++++ ...generated-scenario-scaffolding-contract.md | 6 +- docs/roadmap/generator-roadmap.md | 9 +- 4 files changed, 125 insertions(+), 64 deletions(-) create mode 100644 docs/roadmap/generated-helper-family-matrix.md diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index dd0d683e..56d7f7c9 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -122,67 +122,20 @@ FastMoq now contains a narrow first-party source-generator slice for explicit ha The main value is not "generated mocks" in isolation. The stronger FastMoq-specific opportunity is compile-time provider-first test generation: generated test graphs, harness scaffolding, and framework-helper builders that reduce reflection, reduce boilerplate, and stay aligned with FastMoq-owned APIs. -For the next scenario-contract design slice behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). - -Current public issue crosswalk: - -- foundation and package-shape work: [#120](https://github.com/cwinland/FastMoq/issues/120), [#121](https://github.com/cwinland/FastMoq/issues/121), [#125](https://github.com/cwinland/FastMoq/issues/125), [#126](https://github.com/cwinland/FastMoq/issues/126) for the stable `ScenarioBuilder` scaffolding contract, [#127](https://github.com/cwinland/FastMoq/issues/127), and [#134](https://github.com/cwinland/FastMoq/issues/134) -- near-term helper preparation that can improve v4 authoring before generators ship: [#132](https://github.com/cwinland/FastMoq/issues/132), [#133](https://github.com/cwinland/FastMoq/issues/133), and [#135](https://github.com/cwinland/FastMoq/issues/135) -- completed first implementation-facing MVP: [#122](https://github.com/cwinland/FastMoq/issues/122) -- current shared generated-test settings and test-platform contract gate for later authoring flows: [#162](https://github.com/cwinland/FastMoq/issues/162) -- immediate next-step sequence after the shared `#162` contract: [#126](https://github.com/cwinland/FastMoq/issues/126) for the stable `ScenarioBuilder` scaffolding contract, then [#134](https://github.com/cwinland/FastMoq/issues/134) for helper-surface narrowing and re-triage, then [#136](https://github.com/cwinland/FastMoq/issues/136) for scenario and suite scaffolding implementation -- later implementation-facing outcomes once the contract and scaffold layers are stable: [#123](https://github.com/cwinland/FastMoq/issues/123), [#124](https://github.com/cwinland/FastMoq/issues/124), and later or conditional helper-builder work in [#137](https://github.com/cwinland/FastMoq/issues/137) -- later evaluation tracks after the provider-first generator story is stable: [#138](https://github.com/cwinland/FastMoq/issues/138) and [#139](https://github.com/cwinland/FastMoq/issues/139) +For the detailed issue crosswalk and implementation planning, see [Generator roadmap and design](./generator-roadmap.md). +For the current contract-focused design docs, see [Generated test settings design](./generated-test-settings.md), [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md), and [Generated helper family matrix](./generated-helper-family-matrix.md). Planned direction stays phased: 1. Compile-time test graph and harness generation. -2. Shared generated-test settings and test-platform contract. -3. Stable `ScenarioBuilder` scaffolding contract. -4. Helper-surface narrowing and re-triage for generator-facing helper families. -5. Scenario and suite scaffolding implementation. -6. Full generated tests and analyzer-guided generation once the contract and scaffold layers are stable. -7. Framework-helper builders for repeated helper-heavy test patterns. -8. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. - -Current package and MVP contract direction for [#120](https://github.com/cwinland/FastMoq/issues/120): - -- `FastMoq.Generators` is the dedicated source-generator package -- `FastMoq.Analyzers` remains separate from the source-generator implementation -- the aggregate `FastMoq` package should include the generator path once it ships, while `FastMoq.Core` stays the lighter provider-neutral runtime and does not include the source-generator implementation -- generator capability is install-enabled but target-explicit, not blanket automatic for every eligible type in a project -- unsupported, disabled, missing, or stale generated paths fall back to the supported FastMoq runtime path -- the first implementation-facing MVP is generated graph metadata and harness bootstrap only; scenario scaffolding, full generated tests, helper builders, provider-optimized generation, and compile-time fake generation remain later work -- broader project-level or suite-level generated-test preference settings and test-platform targets are intentionally later than the first MVP and now track through [#162](https://github.com/cwinland/FastMoq/issues/162) - -Current constructor-contract direction for [#125](https://github.com/cwinland/FastMoq/issues/125): - -- [#121](https://github.com/cwinland/FastMoq/issues/121) remains the umbrella tracker; [#125](https://github.com/cwinland/FastMoq/issues/125) is complete, and the public constructor-planning contract is now the settled runtime boundary that the first real [#122](https://github.com/cwinland/FastMoq/issues/122) generator output should target -- the proposed public contract is `InstanceConstructionRequest`, `InstanceConstructionPlan`, `InstanceConstructionParameterPlan`, and `InstanceConstructionParameterSource`, with `Mocker.CreateConstructionPlan(InstanceConstructionRequest request)` as the preferred entry point -- the first graph and harness MVP should keep that `Mocker` surface request-only; the first harness-side consumer can sit on `MockerTestBase` instead of adding a companion generic overload on `Mocker` -- the proposed first-slice parameter-source enum members are `CustomRegistration`, `KnownType`, `KeyedService`, `AutoMock`, `OptionalDefault`, and `TypeDefault`; `ConstructedByMocker` is deferred until the runtime model exposes a distinct recursive-construction parameter category -- the new contract stays narrow while existing public diagnostics, models, creation APIs, and current public reflection-metadata resolution behavior remain part of the compatibility boundary - -Current implementation status for [#127](https://github.com/cwinland/FastMoq/issues/127): - -- done: `FastMoq.Analyzers` now exposes a shared package-layout and target-test-shape matrix for aggregate, core-only, and split helper-package layouts -- done: supported generated test shapes are explicit for core, web, Blazor, database, Azure, and Azure Functions targets based on the referenced FastMoq package set -- done: `MissingHelperPackageAnalyzer` now consumes the same package matrix instead of ad hoc helper-package checks -- done: analyzer tests now cover the core-only, split-helper, and aggregate package layouts that later generator and analyzer flows need to respect -- next: continue [#122](https://github.com/cwinland/FastMoq/issues/122) with the first real source-generator output against the settled planning, graph, harness-bootstrap, and package-shape runtime contracts - -Current implementation status for [#122](https://github.com/cwinland/FastMoq/issues/122): - -- done: an internal construction-graph metadata model now layers over `Mocker.CreateConstructionPlan(...)` without widening the public planning API -- done: `MockerTestBase` has the first graph-facing harness consumer through `GetComponentConstructionGraph()` -- done: an internal harness-bootstrap descriptor now projects `ComponentCreationFlags`, ordered constructor-signature hooks, and explicit-request-override detection on top of the current graph metadata -- done: focused runtime coverage now proves both the root-node mapping and the first harness-bootstrap descriptor paths -- done: the repo now contains a dedicated `FastMoq.Generators` package and the first incremental generator path for explicit partial `MockerTestBase` targets, emitting constructor-signature metadata and harness bootstrap against the settled runtime contract -- done: generator-driver coverage now proves both the single-constructor path and the explicit-signature path compile cleanly while ambiguous multi-constructor targets stay on the normal runtime fallback path -- done: representative generated consuming scenarios now compile against real generated harness output, and parity tests now prove the generated path matches the supported runtime harness path for the same component shapes -- done: measured setup-path evidence is now recorded in [generated harness setup benchmark results](../benchmarks/results/generated-harness-setup-net8.md), where the generated bootstrap-descriptor path holds a slight edge over the runtime fallback path with effectively identical allocations on the richer single-constructor benchmark -- done: the current branch now completes the planned `#122` MVP slice; later generated-test settings and framework or runner targeting move to [#162](https://github.com/cwinland/FastMoq/issues/162) while later scaffolding and full-test generation stay in [#136](https://github.com/cwinland/FastMoq/issues/136), [#123](https://github.com/cwinland/FastMoq/issues/123), and [#124](https://github.com/cwinland/FastMoq/issues/124) -- next: settle the shared generated-test settings and test-platform model in [#162](https://github.com/cwinland/FastMoq/issues/162), then define the stable scenario-scaffolding contract in [#126](https://github.com/cwinland/FastMoq/issues/126), narrow helper-family follow-up in [#134](https://github.com/cwinland/FastMoq/issues/134), and only then widen into [#136](https://github.com/cwinland/FastMoq/issues/136), [#123](https://github.com/cwinland/FastMoq/issues/123), and [#124](https://github.com/cwinland/FastMoq/issues/124) +2. Shared generated-test settings and test-platform contracts. +3. Stable scenario-scaffolding contracts and helper-boundary narrowing. +4. Scenario and suite scaffolding implementation. +5. Full generated tests and analyzer-guided generation once the contract and scaffold layers are stable. +6. Framework-helper builders for repeated helper-heavy test patterns when they justify a separate layer. +7. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. + +The current implementation-facing MVP remains intentionally narrow: generated graph metadata and harness bootstrap are in place, while broader generated-test settings, scenario contracts, helper-boundary narrowing, scenario scaffolding, and full generated tests continue through the linked design docs and the detailed generator roadmap. For the current detailed direction, design constraints, and fuller generator issue mapping, see [Generator roadmap and design](./generator-roadmap.md). For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). diff --git a/docs/roadmap/generated-helper-family-matrix.md b/docs/roadmap/generated-helper-family-matrix.md new file mode 100644 index 00000000..0eda35b3 --- /dev/null +++ b/docs/roadmap/generated-helper-family-matrix.md @@ -0,0 +1,107 @@ +# Generated Helper Family Matrix + +This page captures the current helper-family narrowing contract for generator-facing FastMoq helper surfaces. It is the canonical repo-local design artifact for [#134](https://github.com/cwinland/FastMoq/issues/134), not a shipped feature guide. + +This page is intentionally docs-only. It does not imply that FastMoq currently generates helper-heavy test scaffolds, and it does not itself implement helper normalization. + +For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). +For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). + +## Purpose And Non-Goals + +This slice exists to answer one narrow question for later generated scenario or suite scaffolds: which current FastMoq helper-family entry points may the first scaffold implementation target safely, and under what package or provider conditions? + +The goals are: + +- classify the current helper-family matrix for the first consumer, [#136](https://github.com/cwinland/FastMoq/issues/136) +- identify which helper paths are generator-stable as-is +- identify which helper paths are usable only with explicit package or provider bounds +- identify which helper paths stay deferred from the first generated scenario or suite scaffold path +- split any truly missing normalization work into later follow-on issues instead of absorbing it into this docs pass + +This slice does not: + +- implement helper normalization in runtime code +- redesign the settled `#126` scenario contract or the settled `#162` settings contract +- reopen package-detection and target-shape logic already owned by [#127](https://github.com/cwinland/FastMoq/issues/127) +- define helper-builder generation strategy for [#137](https://github.com/cwinland/FastMoq/issues/137) +- define full generated-test behavior for [#123](https://github.com/cwinland/FastMoq/issues/123) or analyzer routing behavior for [#124](https://github.com/cwinland/FastMoq/issues/124) +- widen into broader observability, tracing, or diagnostics architecture work that belongs elsewhere + +## How #136 Uses This Matrix + +The first generated scenario or suite scaffold implementation should use this matrix as a hard boundary. + +- `#136` may target helper families classified as `generator-stable as-is` directly. +- `#136` may target helper families classified as `generator-stable with explicit bounds` only when the documented package or provider conditions are satisfied. +- `#136` should not target helper families classified as `deferred from first scenario scaffolds`. +- If later generated output needs behavior outside the current stable helper entry points, that work should move to a later follow-on issue instead of quietly widening `#134`. + +## Classification Model + +Each helper family in this matrix is classified into one of four buckets. + +| Classification | Meaning for later generator consumers | +| --- | --- | +| `generator-stable as-is` | The current public helper surface is explicit enough for the first generated scenario or suite scaffolds to target directly. | +| `generator-stable with explicit bounds` | The helper surface is usable for generation only under documented package, provider, or scenario-shape limits. | +| `deferred from first scenario scaffolds` | The helper surface exists, but the first scaffold implementation should not rely on it. | +| `requires follow-on implementation issue` | Later generated output may need this family, but the current public surface is not yet stable enough to target without new public-shape work. | + +## Helper-Family Matrix + +| Helper family | Primary entry points | Classification | First-slice guidance | Ownership notes | +| --- | --- | --- | --- | --- | +| Logging verification | `VerifyLogged(...)`, `VerifyLoggedOnce(...)`, `VerifyNotLogged(...)` | `generator-stable as-is` | Generated scaffolds may use the current provider-neutral logging verification helpers directly. | Reuses the shared verification line from `#133` and analyzer follow-up in `#147`. Broader observability remains outside this issue. | +| Logger-factory composition | `CreateLoggerFactory(...)`, `AddLoggerFactory(...)`, `AddCapturedLoggerFactory(...)` | `generator-stable as-is` | Generated scaffolds may use the current capture-backed logger-factory helpers directly for logging setup. | Reuses existing logging capture and verification surfaces rather than introducing a second abstraction. | +| Typed `IServiceProvider` and `IServiceScope` composition | `CreateTypedServiceProvider(...)`, `CreateTypedServiceScope(...)`, `AddTypedServiceProvider(...)`, `AddTypedServiceScope(...)` | `generator-stable as-is` | Generated scaffolds may rely on the typed provider and scope helpers when a real DI composition path is cleaner than direct registrations. | Reuses the narrow typed-DI quick-win line already stabilized before this slice. | +| ASP.NET Core principal and `HttpContext` helpers | `SetupClaimsPrincipal(...)`, `CreateHttpContext(...)`, `AddHttpContext(...)`, `AddHttpContextAccessor(...)`, `CreateControllerContext(...)` | `generator-stable with explicit bounds` | Generated scaffolds may use these helpers when `FastMoq.Web` is referenced and the generated target actually needs web or controller context composition. | Keep this row scoped to web-context composition, not to broader UI interaction helpers. | +| Azure configuration and typed provider convenience | `CreateAzureConfiguration(...)`, `AddAzureConfiguration(...)`, `CreateAzureServiceProvider(...)`, `AddAzureServiceProvider(...)` | `generator-stable with explicit bounds` | Generated scaffolds may use Azure configuration and typed-provider helpers when `FastMoq.Azure` is referenced. Prefer the Azure typed-provider convenience path over ad hoc lower-level composition where possible. | Package-aware targeting still belongs to `#127`. | +| Azure client, storage, and pageable helpers | `AddAzureClient(...)`, storage client registration helpers, `CreatePageable(...)`, `CreateAsyncPageable(...)` | `generator-stable with explicit bounds` | Generated scaffolds may use the current stable client and pageable helpers for first-pass scenarios, but should not assume keyed or multi-client normalization beyond what the current public surface already expresses. | Later keyed or broader client-shaping needs should split to a later follow-on issue instead of widening this slice. | +| Azure Functions HTTP and `FunctionContext` basics | `CreateHttpRequestData(...)`, `CreateHttpResponseData(...)`, `ReadBodyAsStringAsync(...)`, `ReadBodyAsJsonAsync(...)`, `AddFunctionContextInvocationId(...)`, `CreateFunctionContextInstanceServices()` | `generator-stable with explicit bounds` | Generated scaffolds may use these helpers for HTTP-trigger and basic worker-context scenarios when `FastMoq.AzureFunctions` is referenced. | Reuses the narrower landed Azure Functions helper work, including `#107`. | +| Durable replay-safe logger creation via concrete orchestration context | `Mocker.AddTaskOrchestrationReplaySafeLogging(...)` before resolving `TaskOrchestrationContext` | `generator-stable with explicit bounds` | Generated scaffolds may use the concrete replay-safe logger helper path when the scenario only needs replay-safe logger creation and the helper is registered before resolution. | Keep the documented bounds explicit: logger creation is supported, broader orchestration activity behavior is not. | +| Durable replay-safe logger creation via tracked orchestration mock | `IFastMock.AddTaskOrchestrationReplaySafeLogging(...)` | `deferred from first scenario scaffolds` | The first scaffold implementation should not rely on the tracked orchestration-mock path. It is provider-constrained and does not support replay-state suppression on the tracked path. | Later expansion should align with the provider-contract follow-up already noted around tracked-property configuration support. | +| Blazor component interaction helpers | `MockerBlazorTestBase`, `IMockerBlazorTestHelpers` and related UI interaction helpers | `deferred from first scenario scaffolds` | The first helper matrix should not treat UI-component interaction helpers as required for the initial generated scenario or suite scaffold path. | Keep this for later web or UI expansion rather than mixing it into the first `#136` assumption set. | +| Broader observability, tracing, and diagnostics architecture | Structured trace or diagnostics architecture beyond current logging capture and verification | `requires follow-on implementation issue` | Do not make the first generated scaffold depend on a broader observability stack than the current repo-owned logging capture and verification helpers already provide. | Broader structured diagnostics or tracing remains outside this issue. | + +## Package And Provider Constraints + +This matrix does not replace package-aware or provider-aware capability checks. + +- [#127](https://github.com/cwinland/FastMoq/issues/127) remains the authoritative package-detection and target-shape gate. +- Package-specific helper families may only be emitted when the referenced FastMoq package set makes that helper family valid for the current project. +- The Durable tracked-mock replay-safe logger path remains provider-constrained and should not be treated as provider-neutral scaffolding guidance. +- The concrete replay-safe orchestration helper remains intentionally narrow: it supports logger creation and replay-state assertions, not full orchestration activity, timer, event, or sub-orchestrator behavior. + +## Dependency Boundaries + +`#134` exists to narrow helper-family assumptions, not to re-own adjacent work that already landed or already has a narrower issue. + +- [#132](https://github.com/cwinland/FastMoq/issues/132) owns the shared setup-helper expansion line for fixed returns, async returns, callbacks, and exception helpers. +- [#133](https://github.com/cwinland/FastMoq/issues/133) owns the shared verification-helper expansion line, including the current logging verification story. +- [#135](https://github.com/cwinland/FastMoq/issues/135) owns the earlier quick-win cleanup for logging, HTTP, and typed-DI entry points that this matrix now reuses rather than redesigns. +- [#107](https://github.com/cwinland/FastMoq/issues/107) owns the landed Azure Functions InvocationId and replay-safe logger guidance that this matrix now classifies for generator-facing use. +- [#145](https://github.com/cwinland/FastMoq/issues/145) or later observability work owns broader structured diagnostics or tracing concerns outside the current helper capture or verification surface. +- [#137](https://github.com/cwinland/FastMoq/issues/137) remains later helper-builder generation and should not be designed inside this narrowing pass. + +## Split Criteria For Later Follow-On Work + +`#134` should stop and point to later follow-on work when a generator need exceeds the current stable helper surface. + +Open or route to a later issue when one of these becomes necessary: + +- a helper family needs a new public entry point instead of relying on the current repo-owned helper surface +- the desired generated flow depends on keyed or multi-client semantics that the current helper API does not make explicit +- the desired generated flow depends on provider-native or protected-member behaviors that are not part of the shared provider-first helper guidance +- the desired generated flow depends on Azure Functions orchestration behavior beyond replay-safe logger creation +- the desired generated flow depends on Blazor UI interaction helpers rather than simple web-context composition +- the desired generated flow depends on broader structured observability or diagnostics architecture rather than the current logging capture helpers + +## Follow-On Boundaries + +The immediate follow-on order after this docs pass should remain explicit: + +- `#134` ends once the helper-family matrix, package or provider bounds, and split criteria are documented and mirrored into the roadmap. +- `#136` should then implement generated scenario and suite scaffolding against the `#126` scenario contract, this helper-family matrix, and the `#162` settings contract. +- `#123` and `#124` should consume the same helper assumptions later instead of inventing local defaults or re-triaging helper families inside their own slices. +- `#137` remains later and conditional rather than part of the immediate `#126 -> #134 -> #136 -> #123/#124` chain. diff --git a/docs/roadmap/generated-scenario-scaffolding-contract.md b/docs/roadmap/generated-scenario-scaffolding-contract.md index dbdd0870..6673cba1 100644 --- a/docs/roadmap/generated-scenario-scaffolding-contract.md +++ b/docs/roadmap/generated-scenario-scaffolding-contract.md @@ -22,7 +22,7 @@ This slice does not: - implement source generation for scenario or suite scaffolds - redesign `ScenarioBuilder` into a separate scenario runtime model - reopen constructor-selection, graph metadata, or harness bootstrap contracts already settled in `#125` and `#122` -- settle helper-family normalization for logging, HTTP, Azure, Azure Functions, or typed DI beyond the boundaries needed to keep the scenario contract narrow +- settle the helper-family narrowing matrix for logging, HTTP, Azure, Azure Functions, or typed DI beyond the boundaries needed to keep the scenario contract narrow - replace the settings, hook-emission-style, or regeneration-policy ownership already documented in `#162` ## Current Runtime Anchors @@ -121,7 +121,7 @@ In practice that means: The following work stays outside `#126` even when it is closely related: -- helper-family normalization across logging, HTTP, Azure, Azure Functions, and typed DI stays in [#134](https://github.com/cwinland/FastMoq/issues/134) +- helper-family narrowing and re-triage across logging, HTTP, Azure, Azure Functions, and typed DI stays in [#134](https://github.com/cwinland/FastMoq/issues/134) and is documented in [Generated helper family matrix](./generated-helper-family-matrix.md) - concrete generated scenario and suite scaffolding implementation stays in [#136](https://github.com/cwinland/FastMoq/issues/136) - full generated tests from existing services and supported classes stays in [#123](https://github.com/cwinland/FastMoq/issues/123) - analyzer-guided generation routing and missing-package suggestions stays in [#124](https://github.com/cwinland/FastMoq/issues/124) @@ -134,7 +134,7 @@ If later discovery shows that one truly missing runtime seam blocks the contract The immediate sequence after `#162` should stay explicit: - `#126` is the next design slice and defines the stable scenario-scaffolding contract -- `#134` is the next narrowing and re-triage pass for helper-family normalization that the scenario implementation may later depend on +- `#134` is the next narrowing and re-triage pass that classifies which helper families the scenario implementation may later depend on - `#136` is the next implementation-facing PR once the scenario contract and helper boundaries are explicit - `#123` and `#124` follow only after the contract and scaffold layers are stable enough to consume instead of re-deriving local defaults - `#137` remains later and conditional rather than part of the immediate next-step chain diff --git a/docs/roadmap/generator-roadmap.md b/docs/roadmap/generator-roadmap.md index da0d6b70..b12ea94a 100644 --- a/docs/roadmap/generator-roadmap.md +++ b/docs/roadmap/generator-roadmap.md @@ -6,6 +6,7 @@ This page is intentionally design-level only. It is appropriate for roadmap and For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). +For the helper-family narrowing contract behind [#134](https://github.com/cwinland/FastMoq/issues/134), see [Generated helper family matrix](./generated-helper-family-matrix.md). FastMoq now contains a narrow first Roslyn source-generator slice for explicit `MockerTestBase` harness targets. The repo still does not emit full generated tests, scenario scaffolds, or broader framework-helper builders. @@ -31,7 +32,7 @@ The current public backlog for this design is: - [#125](https://github.com/cwinland/FastMoq/issues/125) graph metadata hooks and constructor-selection primitives - [#126](https://github.com/cwinland/FastMoq/issues/126) `ScenarioBuilder` scaffolding hooks for generated output - [#127](https://github.com/cwinland/FastMoq/issues/127) package-detection and target-test-shape rules for generated tests -- [#134](https://github.com/cwinland/FastMoq/issues/134) blocking helper-surface normalization for logging, HTTP, Azure, Azure Functions, and typed DI +- [#134](https://github.com/cwinland/FastMoq/issues/134) helper-family narrowing and re-triage matrix for logging, HTTP, Azure, Azure Functions, and typed DI - [#122](https://github.com/cwinland/FastMoq/issues/122) compile-time graph and harness MVP - [#162](https://github.com/cwinland/FastMoq/issues/162) generated-test settings and test-platform targeting as the current gate before wider authoring flows - [#136](https://github.com/cwinland/FastMoq/issues/136) generated scenario and suite scaffolding after the graph and harness MVP @@ -46,11 +47,11 @@ Crosswalk summary: - `#121` is the runtime-prerequisite umbrella. - `#132`, `#133`, and `#135` are the narrower v4-style quick wins that are now implemented on the current milestone branch. - `#146` and `#147` carry the near-term analyzer follow-up for those landed helper surfaces. -- `#120`, `#125`, `#126`, `#127`, and `#134` are the pre-v5 contract and blocking prerequisite slices. +- `#120`, `#125`, `#126`, `#127`, and `#134` are the pre-v5 contract and helper-boundary prerequisite slices. - `#122` is the completed first implementation-facing MVP for compile-time graph metadata and harness bootstrap. - `#162` is the current shared settings and test-platform contract gate before wider generated scenario scaffolds, full generated tests, and analyzer entry points. - `#126` is the immediate next design slice after `#162` and defines the stable scenario-scaffolding contract layer. -- `#134` is the next narrowing and re-triage pass for helper-family normalization after `#126`. +- `#134` is the next narrowing and re-triage pass after `#126`, and it classifies which helper families are green-light, bounded, deferred, or split to later follow-on work for `#136`. - `#136` is the next implementation slice once the scenario contract and helper boundaries are explicit. - `#123` and `#124` remain later phased implementation and authoring-flow outcomes after the contract and scaffold layers are stable. - `#137` remains later and conditional rather than part of the immediate next-step chain. @@ -243,7 +244,7 @@ Completed on the current milestone branch: - expanded provider-first verification helpers where a shared abstraction is still clear and stable - small helper-surface cleanups for logging, HTTP, or typed DI setup where the FastMoq-owned runtime surface already exists and only needed a more generator-friendly shape -These landed early because they improve normal authoring even before source generators ship. The remaining near-term follow-up is narrower analyzer guidance in [#146](https://github.com/cwinland/FastMoq/issues/146) and [#147](https://github.com/cwinland/FastMoq/issues/147), plus the narrower helper-family re-triage and normalization pass in [#134](https://github.com/cwinland/FastMoq/issues/134) after the `#126` scenario contract is explicit. +These landed early because they improve normal authoring even before source generators ship. The remaining near-term follow-up is narrower analyzer guidance in [#146](https://github.com/cwinland/FastMoq/issues/146) and [#147](https://github.com/cwinland/FastMoq/issues/147), plus the helper-family narrowing matrix in [#134](https://github.com/cwinland/FastMoq/issues/134) after the `#126` scenario contract is explicit. ### Likely v5 blocking prerequisites From ccbd62ac392a141750ffa7df3b126143f9c56988 Mon Sep 17 00:00:00 2001 From: Chris Winland <42553203+cwinland@users.noreply.github.com> Date: Mon, 4 May 2026 17:02:32 -0400 Subject: [PATCH 5/6] Align generator project GUID across solutions --- FastMoq-Release.sln | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/FastMoq-Release.sln b/FastMoq-Release.sln index 40fec51d..448ea37c 100644 --- a/FastMoq-Release.sln +++ b/FastMoq-Release.sln @@ -7,7 +7,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FastMoq", "FastMoq\FastMoq. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FastMoq.Abstractions", "FastMoq.Abstractions\FastMoq.Abstractions.csproj", "{970828D1-0EF2-4D0D-BF1B-BB85DEB38514}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FastMoq.Generators", "FastMoq.Generators\FastMoq.Generators.csproj", "{3A865DC1-4C90-42F7-B0E9-25A31D7C432E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FastMoq.Generators", "FastMoq.Generators\FastMoq.Generators.csproj", "{5FE82C4B-17E0-4C41-9A79-4F13FCB6D2D1}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FastMoq.Azure", "FastMoq.Azure\FastMoq.Azure.csproj", "{CADD0874-D3E6-40B3-A8D5-EB04047597EC}" EndProject @@ -39,10 +39,10 @@ Global {970828D1-0EF2-4D0D-BF1B-BB85DEB38514}.Debug|Any CPU.Build.0 = Release|Any CPU {970828D1-0EF2-4D0D-BF1B-BB85DEB38514}.Release|Any CPU.ActiveCfg = Release|Any CPU {970828D1-0EF2-4D0D-BF1B-BB85DEB38514}.Release|Any CPU.Build.0 = Release|Any CPU - {3A865DC1-4C90-42F7-B0E9-25A31D7C432E}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {3A865DC1-4C90-42F7-B0E9-25A31D7C432E}.Debug|Any CPU.Build.0 = Release|Any CPU - {3A865DC1-4C90-42F7-B0E9-25A31D7C432E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3A865DC1-4C90-42F7-B0E9-25A31D7C432E}.Release|Any CPU.Build.0 = Release|Any CPU + {5FE82C4B-17E0-4C41-9A79-4F13FCB6D2D1}.Debug|Any CPU.ActiveCfg = Release|Any CPU + {5FE82C4B-17E0-4C41-9A79-4F13FCB6D2D1}.Debug|Any CPU.Build.0 = Release|Any CPU + {5FE82C4B-17E0-4C41-9A79-4F13FCB6D2D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FE82C4B-17E0-4C41-9A79-4F13FCB6D2D1}.Release|Any CPU.Build.0 = Release|Any CPU {CADD0874-D3E6-40B3-A8D5-EB04047597EC}.Debug|Any CPU.ActiveCfg = Release|Any CPU {CADD0874-D3E6-40B3-A8D5-EB04047597EC}.Debug|Any CPU.Build.0 = Release|Any CPU {CADD0874-D3E6-40B3-A8D5-EB04047597EC}.Release|Any CPU.ActiveCfg = Release|Any CPU From a6539713ab00070c466616a940bd2acdb5bf701d Mon Sep 17 00:00:00 2001 From: Christopher Winland <42553203+cwinland@users.noreply.github.com> Date: Wed, 6 May 2026 15:32:19 -0400 Subject: [PATCH 6/6] Add generated shared setup scenario scaffold hooks (#164) * Expand generated scenario scaffold executors * Add generated shared setup scaffold hooks * Strengthen generated shared setup hook ordering test * Document generated scenario scaffold members * docs: align generator roadmap with current scope * Widen generated harness smoke test coverage (#165) * Widen generated harness smoke test coverage * Fix: Escape C# keywords in generated method invocations When component methods are named with C# keywords (e.g., @class, @return), the unescaped IMethodSymbol.Name resulted in invalid emitted code like 'component.class()' instead of 'component.@class()'. - Add EscapeIdentifierIfKeyword helper using SyntaxFacts.GetKeywordKind - Apply escaping in CreateGeneratedTestMethodModel to all method references - Add [Fact] test covering keyword methods (@class, @interface, @return) Addresses code review comment on GeneratedHarnessSourceGenerator.cs * feat(generators): wire FastMoqGeneratedTestFramework MSBuild property into generator pipeline (#123 slice 2) (#166) Wire FastMoqGeneratedTestFramework MSBuild property into generator pipeline - Add CompilerVisibleProperty for FastMoqGeneratedTestFramework to generator csproj - Read build_property.FastMoqGeneratedTestFramework in incremental pipeline via AnalyzerConfigOptionsProvider - Combine frameworkSetting with targets pipeline; pass to EmitSource - When value is 'none' (case-insensitive), suppress xUnit smoke-test emission regardless of metadata - Add TestAnalyzerConfigOptionsProvider / TestAnalyzerConfigOptions nested helpers for test isolation - Add 3 new [Fact] tests covering none suppression, explicit xunit enable, and case-insensitive none Part of #123 --- .../AnalyzerTestHelpers.cs | 35 +- .../GeneratedHarnessSourceGeneratorTests.cs | 924 +++++++++++++++++- FastMoq.Generators/FastMoq.Generators.csproj | 4 + .../GeneratedHarnessSourceGenerator.cs | 602 +++++++++++- docs/roadmap/README.md | 16 +- .../roadmap/generated-helper-family-matrix.md | 5 +- ...generated-scenario-scaffolding-contract.md | 30 +- docs/roadmap/generated-test-settings.md | 20 +- docs/roadmap/generator-roadmap.md | 92 +- 9 files changed, 1660 insertions(+), 68 deletions(-) diff --git a/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs b/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs index 60c41b83..7e5b49f1 100644 --- a/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs +++ b/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs @@ -24,6 +24,11 @@ internal static class AnalyzerTestHelpers "FastMoq.Tests.Web", }; + private static bool IsXunitAssemblyName(string assemblyName) + { + return assemblyName.StartsWith("xunit", StringComparison.OrdinalIgnoreCase); + } + public static async Task> GetDiagnosticsAsync(string source, params DiagnosticAnalyzer[] analyzers) { return await GetDiagnosticsAsync(source, includeAzureFunctionsHelpers: false, includeMoqProviderPackage: true, includeNSubstituteProviderPackage: true, includeWebHelpers: true, analyzers).ConfigureAwait(false); @@ -148,7 +153,8 @@ public static Document CreateDocumentForTest( bool includeWebHelpers = true, bool includeDatabaseHelpers = false, bool includeAzureHelpers = false, - bool includeAggregatePackage = false) + bool includeAggregatePackage = false, + bool includeXunit = true) { return CreateDocument( source, @@ -158,7 +164,8 @@ public static Document CreateDocumentForTest( includeWebHelpers, includeDatabaseHelpers, includeAzureHelpers, - includeAggregatePackage); + includeAggregatePackage, + includeXunit); } private static Document CreateDocument( @@ -169,7 +176,8 @@ private static Document CreateDocument( bool includeWebHelpers = true, bool includeDatabaseHelpers = false, bool includeAzureHelpers = false, - bool includeAggregatePackage = false) + bool includeAggregatePackage = false, + bool includeXunit = true) { var project = CreateProject( [("Test.cs", source)], @@ -179,7 +187,8 @@ private static Document CreateDocument( includeWebHelpers, includeDatabaseHelpers, includeAzureHelpers, - includeAggregatePackage); + includeAggregatePackage, + includeXunit); return project.Documents.Single(); } @@ -191,7 +200,8 @@ private static Project CreateProject( bool includeWebHelpers = true, bool includeDatabaseHelpers = false, bool includeAzureHelpers = false, - bool includeAggregatePackage = false) + bool includeAggregatePackage = false, + bool includeXunit = true) { var workspace = new AdhocWorkspace(); var projectId = ProjectId.CreateNewId(); @@ -208,7 +218,8 @@ private static Project CreateProject( includeWebHelpers, includeDatabaseHelpers, includeAzureHelpers, - includeAggregatePackage)) + includeAggregatePackage, + includeXunit)) { solution = solution.AddMetadataReference(projectId, metadataReference); } @@ -229,7 +240,8 @@ private static IEnumerable GetMetadataReferences( bool includeWebHelpers, bool includeDatabaseHelpers, bool includeAzureHelpers, - bool includeAggregatePackage) + bool includeAggregatePackage, + bool includeXunit) { if (includeAggregatePackage) { @@ -250,6 +262,11 @@ private static IEnumerable GetMetadataReferences( continue; } + if (!includeXunit && IsXunitAssemblyName(assemblyName)) + { + continue; + } + if (!includeAggregatePackage && string.Equals(assemblyName, "FastMoq", StringComparison.OrdinalIgnoreCase)) { @@ -335,6 +352,10 @@ private static IEnumerable GetMetadataReferences( references.Add(typeof(Moq.Mock).Assembly.Location); references.Add(typeof(NSubstitute.Substitute).Assembly.Location); + if (includeXunit) + { + references.Add(typeof(global::Xunit.FactAttribute).Assembly.Location); + } references.Add(typeof(Microsoft.Extensions.Logging.ILogger).Assembly.Location); references.Add(typeof(Microsoft.AspNetCore.Http.DefaultHttpContext).Assembly.Location); references.Add(typeof(Microsoft.AspNetCore.Mvc.ControllerContext).Assembly.Location); diff --git a/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs b/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs index e0a9bf55..e7f08b26 100644 --- a/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs +++ b/FastMoq.Analyzers.Tests/GeneratedHarnessSourceGeneratorTests.cs @@ -2,6 +2,7 @@ using FastMoq.Generators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using System.Collections.Immutable; using System.IO; using System.Linq; @@ -289,8 +290,703 @@ public async Task GeneratedHarnessSourceGenerator_ShouldMatchManualRuntimeHarnes dependencyTypes.Should().Equal(generatedPlan.Parameters.Select(static parameter => parameter.ParameterType)); } - private static async Task RunGeneratorAsync(string source) + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitExecutableScenarioScaffold_ForGeneratedHarnessTarget() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class ScenarioCounter +{ + public int Count { get; private set; } + + public bool WasVerified { get; private set; } + + public void Increment() + { + Count++; + } + + public void MarkVerified() + { + WasVerified = true; + } +} + +[FastMoqGeneratedTestTarget(typeof(ScenarioCounter))] +public partial class GeneratedScenarioHarness : MockerTestBase +{ + public int DescribeCount() => Component.Count; + + public bool DescribeWasVerified() => Component.WasVerified; + + partial void ActGeneratedScenario(ScenarioBuilder scenario) + { + scenario.When(component => component.Increment()); + } + + partial void AssertGeneratedScenario(ScenarioBuilder scenario) + { + scenario.Then(component => + { + if (component.Count != 1) + { + throw new global::System.InvalidOperationException(""Expected exactly one increment.""); + } + }); + } + + partial void VerifyGeneratedScenario(ScenarioBuilder scenario) + { + scenario.Then(component => component.MarkVerified()); + } +} +"; + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedScenarioHarness"); + + Invoke(generatedHarness, "ExecuteGeneratedScenarioScaffold"); + + Invoke(generatedHarness, "DescribeCount").Should().Be(1); + Invoke(generatedHarness, "DescribeWasVerified").Should().BeTrue(); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitExecutableAsyncScenarioScaffold_ForGeneratedHarnessTarget() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class AsyncScenarioCounter +{ + public int Count { get; private set; } + + public bool WasVerified { get; private set; } + + public void Increment() + { + Count++; + } + + public void MarkVerified() + { + WasVerified = true; + } +} + +[FastMoqGeneratedTestTarget(typeof(AsyncScenarioCounter))] +public partial class GeneratedAsyncScenarioHarness : MockerTestBase +{ + public int DescribeCount() => Component.Count; + + public bool DescribeWasVerified() => Component.WasVerified; + + partial void ActGeneratedScenario(ScenarioBuilder scenario) + { + scenario.When(async component => + { + await global::System.Threading.Tasks.Task.CompletedTask; + component.Increment(); + }); + } + + partial void AssertGeneratedScenario(ScenarioBuilder scenario) + { + scenario.Then(async component => + { + await global::System.Threading.Tasks.Task.CompletedTask; + if (component.Count != 1) + { + throw new global::System.InvalidOperationException(""Expected exactly one increment.""); + } + }); + } + + partial void VerifyGeneratedScenario(ScenarioBuilder scenario) + { + scenario.Then(async component => + { + await global::System.Threading.Tasks.Task.CompletedTask; + component.MarkVerified(); + }); + } +} +"; + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedAsyncScenarioHarness"); + + await Invoke(generatedHarness, "ExecuteGeneratedScenarioScaffoldAsync"); + + Invoke(generatedHarness, "DescribeCount").Should().Be(1); + Invoke(generatedHarness, "DescribeWasVerified").Should().BeTrue(); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitExecutableExpectedExceptionScenarioScaffold_ForGeneratedHarnessTarget() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class ThrowingScenarioCounter +{ + public bool AssertedAfterExpectedException { get; private set; } + + public bool VerifiedAfterExpectedException { get; private set; } + + public void Throw() + { + throw new global::System.InvalidOperationException(""boom""); + } + + public void MarkAsserted() + { + AssertedAfterExpectedException = true; + } + + public void MarkVerified() + { + VerifiedAfterExpectedException = true; + } +} + +[FastMoqGeneratedTestTarget(typeof(ThrowingScenarioCounter))] +public partial class GeneratedExpectedExceptionScenarioHarness : MockerTestBase +{ + public bool DescribeAssertedAfterExpectedException() => Component.AssertedAfterExpectedException; + + public bool DescribeVerifiedAfterExpectedException() => Component.VerifiedAfterExpectedException; + + partial void ExpectedExceptionGeneratedScenario(ScenarioBuilder scenario) where TException : global::System.Exception + { + scenario.WhenThrows(component => component.Throw()); + } + + partial void AssertGeneratedScenario(ScenarioBuilder scenario) + { + scenario.Then(component => component.MarkAsserted()); + } + + partial void VerifyGeneratedScenario(ScenarioBuilder scenario) + { + scenario.Then(component => component.MarkVerified()); + } +} +"; + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedExpectedExceptionScenarioHarness"); + + InvokeGenericVoid(generatedHarness, "ExecuteGeneratedExpectedExceptionScenarioScaffold", typeof(global::System.InvalidOperationException)); + + Invoke(generatedHarness, "DescribeAssertedAfterExpectedException").Should().BeTrue(); + Invoke(generatedHarness, "DescribeVerifiedAfterExpectedException").Should().BeTrue(); + + await InvokeGeneric(generatedHarness, "ExecuteGeneratedExpectedExceptionScenarioScaffoldAsync", typeof(global::System.InvalidOperationException)); + + Invoke(generatedHarness, "DescribeAssertedAfterExpectedException").Should().BeTrue(); + Invoke(generatedHarness, "DescribeVerifiedAfterExpectedException").Should().BeTrue(); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitSuiteLevelSharedSetupHooks_ForGeneratedHarnessTarget() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public interface ISharedSetupDependency +{ + string Name { get; } +} + +public sealed class SharedSetupDependency : ISharedSetupDependency +{ + public string Name => ""shared""; +} + +public sealed class SharedSetupTarget +{ + public SharedSetupTarget(ISharedSetupDependency dependency) + { + DependencyName = dependency.Name; + } + + public string DependencyName { get; } + + public bool WasCreatedHookApplied { get; private set; } + + public bool WasScenarioExecuted { get; private set; } + + public void MarkCreatedHookApplied() + { + WasCreatedHookApplied = true; + } + + public void MarkScenarioExecuted() + { + WasScenarioExecuted = true; + } +} + +[FastMoqGeneratedTestTarget(typeof(SharedSetupTarget))] +public partial class GeneratedSharedSetupHarness : MockerTestBase +{ + public string DescribeDependencyName() => Component.DependencyName; + + public bool DescribeWasCreatedHookApplied() => Component.WasCreatedHookApplied; + + public bool DescribeWasScenarioExecuted() => Component.WasScenarioExecuted; + + public bool? DescribeStrictPolicy() => Mocks.Policy.DefaultStrictMockCreation; + + partial void ConfigureGeneratedMockerPolicy(MockerPolicyOptions options) + { + options.DefaultStrictMockCreation = true; + } + + partial void ConfigureGeneratedMocks(Mocker mocker) + { + mocker.AddType(new SharedSetupDependency()); + } + + partial void AfterGeneratedComponentCreated(SharedSetupTarget component) + { + component.MarkCreatedHookApplied(); + } + + partial void ActGeneratedScenario(ScenarioBuilder scenario) + { + scenario.When(component => component.MarkScenarioExecuted()); + } +} +"; + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedSharedSetupHarness"); + + Invoke(generatedHarness, "DescribeDependencyName").Should().Be("shared"); + Invoke(generatedHarness, "DescribeWasCreatedHookApplied").Should().BeTrue(); + Invoke(generatedHarness, "DescribeStrictPolicy").Should().BeTrue(); + + Invoke(generatedHarness, "ExecuteGeneratedScenarioScaffold"); + + Invoke(generatedHarness, "DescribeWasScenarioExecuted").Should().BeTrue(); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldPreserveSuiteLevelSharedSetupHookOrder_ForGeneratedHarnessTarget() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public static class SharedSetupEventLog +{ + public static global::System.Collections.Generic.List Events { get; } = new global::System.Collections.Generic.List(); + + public static void Record(string eventName) + { + Events.Add(eventName); + } + + public static string Describe() + { + return global::System.String.Join(""|"", Events); + } +} + +public interface ISharedSetupDependency +{ + string Name { get; } +} + +public sealed class SharedSetupDependency : ISharedSetupDependency +{ + public string Name => ""shared""; +} + +public sealed class SharedSetupTarget +{ + public SharedSetupTarget(ISharedSetupDependency dependency) + { + SharedSetupEventLog.Record(""constructed""); + DependencyName = dependency.Name; + } + + public string DependencyName { get; } + + public bool WasCreatedHookApplied { get; private set; } + + public bool WasScenarioExecuted { get; private set; } + + public void MarkCreatedHookApplied() + { + WasCreatedHookApplied = true; + } + + public void MarkScenarioExecuted() + { + WasScenarioExecuted = true; + } +} + +public abstract class SharedSetupHarnessBase : MockerTestBase +{ + protected override global::System.Action? ConfigureMockerPolicy => + _ => SharedSetupEventLog.Record(""base-policy""); + + protected override global::System.Action? SetupMocksAction => + _ => SharedSetupEventLog.Record(""base-setup""); + + protected override global::System.Action? CreatedComponentAction => + _ => SharedSetupEventLog.Record(""base-created""); +} + +[FastMoqGeneratedTestTarget(typeof(SharedSetupTarget))] +public partial class GeneratedSharedSetupHarness : SharedSetupHarnessBase +{ + public string DescribeDependencyName() => Component.DependencyName; + + public bool DescribeWasCreatedHookApplied() => Component.WasCreatedHookApplied; + + public bool DescribeWasScenarioExecuted() => Component.WasScenarioExecuted; + + public bool? DescribeStrictPolicy() => Mocks.Policy.DefaultStrictMockCreation; + + public string DescribeHookOrder() => SharedSetupEventLog.Describe(); + + partial void ConfigureGeneratedMockerPolicy(MockerPolicyOptions options) + { + SharedSetupEventLog.Record(""generated-policy""); + options.DefaultStrictMockCreation = true; + } + + partial void ConfigureGeneratedMocks(Mocker mocker) + { + SharedSetupEventLog.Record(""generated-setup""); + mocker.AddType(new SharedSetupDependency()); + } + + partial void AfterGeneratedComponentCreated(SharedSetupTarget component) + { + SharedSetupEventLog.Record(""generated-created""); + component.MarkCreatedHookApplied(); + } + + partial void ActGeneratedScenario(ScenarioBuilder scenario) + { + scenario.When(component => + { + SharedSetupEventLog.Record(""act""); + component.MarkScenarioExecuted(); + }); + } +} +"; + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedSharedSetupHarness"); + + Invoke(generatedHarness, "DescribeDependencyName").Should().Be("shared"); + Invoke(generatedHarness, "DescribeWasCreatedHookApplied").Should().BeTrue(); + Invoke(generatedHarness, "DescribeWasScenarioExecuted").Should().BeFalse(); + Invoke(generatedHarness, "DescribeStrictPolicy").Should().BeTrue(); + Invoke(generatedHarness, "DescribeHookOrder").Should().Be("base-policy|generated-policy|base-setup|generated-setup|constructed|base-created|generated-created"); + + Invoke(generatedHarness, "ExecuteGeneratedScenarioScaffold"); + + Invoke(generatedHarness, "DescribeWasScenarioExecuted").Should().BeTrue(); + Invoke(generatedHarness, "DescribeHookOrder").Should().Be("base-policy|generated-policy|base-setup|generated-setup|constructed|base-created|generated-created|act"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitExecutableXunitSmokeTests_ForParameterlessPublicMethods() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class SmokeTarget +{ + public int Count { get; private set; } + + public int GetCount() + { + return Count; + } + + public void Increment() + { + Count++; + } + + public async global::System.Threading.Tasks.Task IncrementAsync() + { + await global::System.Threading.Tasks.Task.CompletedTask; + Count++; + } +} + +[FastMoqGeneratedTestTarget(typeof(SmokeTarget))] +public partial class GeneratedSmokeHarness : MockerTestBase +{ + public int DescribeCount() => Component.Count; +} +"; + + var result = await RunGeneratorAsync(source); + + result.DriverDiagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().Contain("[global::Xunit.Fact]"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_00_Component_ShouldCreateComponent"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_01_GetCount_ShouldExecuteWithoutThrowing"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_02_Increment_ShouldExecuteWithoutThrowing"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_03_IncrementAsync_ShouldExecuteWithoutThrowing"); + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedSmokeHarness"); + + InvokeVoid(generatedHarness, "FastMoqGeneratedSmokeTest_00_Component_ShouldCreateComponent"); + InvokeVoid(generatedHarness, "FastMoqGeneratedSmokeTest_01_GetCount_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeCount").Should().Be(0); + + InvokeVoid(generatedHarness, "FastMoqGeneratedSmokeTest_02_Increment_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeCount").Should().Be(1); + + await Invoke(generatedHarness, "FastMoqGeneratedSmokeTest_03_IncrementAsync_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeCount").Should().Be(2); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitExecutableXunitSmokeTests_ForMethodsWithOptionalDefaults_AndValueTaskShapes() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class OptionalDefaultsTarget +{ + public string LastMessage { get; private set; } = string.Empty; + + public int Count { get; private set; } + + public int Add(int amount = 3) + { + Count += amount; + return Count; + } + + public global::System.Threading.Tasks.ValueTask LoadAsync(string prefix = ""value"", int suffix = 7) + { + LastMessage = prefix + suffix; + return new global::System.Threading.Tasks.ValueTask(LastMessage); + } + + public global::System.Threading.Tasks.ValueTask ResetAsync(bool enabled = true) + { + if (enabled) + { + Count = 0; + } + + return global::System.Threading.Tasks.ValueTask.CompletedTask; + } +} + +[FastMoqGeneratedTestTarget(typeof(OptionalDefaultsTarget))] +public partial class GeneratedOptionalDefaultsHarness : MockerTestBase +{ + public int DescribeCount() => Component.Count; + + public string DescribeLastMessage() => Component.LastMessage; +} +"; + + var result = await RunGeneratorAsync(source); + + result.DriverDiagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_01_Add_ShouldExecuteWithoutThrowing"); + generatedSource.Should().Contain("_ = component.Add((int)3);"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_02_LoadAsync_ShouldExecuteWithoutThrowing"); + generatedSource.Should().Contain("_ = await component.LoadAsync((string)\"value\", (int)7);"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_03_ResetAsync_ShouldExecuteWithoutThrowing"); + generatedSource.Should().Contain("await component.ResetAsync((bool)true);"); + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedOptionalDefaultsHarness"); + + InvokeVoid(generatedHarness, "FastMoqGeneratedSmokeTest_01_Add_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeCount").Should().Be(3); + + await Invoke(generatedHarness, "FastMoqGeneratedSmokeTest_02_LoadAsync_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeLastMessage").Should().Be("value7"); + + await Invoke(generatedHarness, "FastMoqGeneratedSmokeTest_03_ResetAsync_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeCount").Should().Be(0); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldBindOverloadedMethodsUsingTypedOptionalDefaults() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class OverloadedOptionalDefaultsTarget +{ + public string LastCall { get; private set; } = string.Empty; + + public void Choose(object? value = null) + { + LastCall = value is null ? ""object-null"" : value.ToString()!; + } + + public void Choose(string value = ""text"") + { + LastCall = ""string:"" + value; + } +} + +[FastMoqGeneratedTestTarget(typeof(OverloadedOptionalDefaultsTarget))] +public partial class GeneratedOverloadedOptionalDefaultsHarness : MockerTestBase +{ + public string DescribeLastCall() => Component.LastCall; +} +"; + + var result = await RunGeneratorAsync(source); + + result.DriverDiagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().Contain("component.Choose((object)null);"); + generatedSource.Should().Contain("component.Choose((string)\"text\");"); + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedOverloadedOptionalDefaultsHarness"); + + InvokeVoid(generatedHarness, "FastMoqGeneratedSmokeTest_01_Choose_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeLastCall").Should().Be("object-null"); + + InvokeVoid(generatedHarness, "FastMoqGeneratedSmokeTest_02_Choose_ShouldExecuteWithoutThrowing"); + Invoke(generatedHarness, "DescribeLastCall").Should().Be("string:text"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitDeferredXunitPlaceholders_ForUnsupportedPublicMethods() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class DeferredSmokeTarget +{ + public global::System.Threading.Tasks.ValueTask LoadAsync() + { + return new global::System.Threading.Tasks.ValueTask(1); + } + + public void Process(string value) + { + } +} + +[FastMoqGeneratedTestTarget(typeof(DeferredSmokeTarget))] +public partial class GeneratedDeferredSmokeHarness : MockerTestBase +{ +} +"; + + var result = await RunGeneratorAsync(source); + + result.DriverDiagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().Contain("[global::Xunit.Fact]"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_01_LoadAsync_ShouldExecuteWithoutThrowing"); + generatedSource.Should().Contain("[global::Xunit.Fact(Skip = \"FastMoq generated smoke test deferred: method 'void DeferredSmokeTarget.Process(string value)' requires non-optional parameters.\")]"); + generatedSource.Should().Contain("FastMoqGeneratedPlaceholder_02_Process_IsDeferred"); + + var loadedAssembly = await LoadGeneratedAssemblyAsync(source); + var generatedHarness = CreateInstance(loadedAssembly, "Demo.Tests.GeneratedDeferredSmokeHarness"); + + await Invoke(generatedHarness, "FastMoqGeneratedSmokeTest_01_LoadAsync_ShouldExecuteWithoutThrowing"); + + var process = () => InvokeVoid(generatedHarness, "FastMoqGeneratedPlaceholder_02_Process_IsDeferred"); + process.Should().Throw() + .WithInnerException() + .WithMessage("*DeferredSmokeTarget.Process(string value)*requires non-optional parameters*"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldNotEmitXunitSmokeTests_WhenXunitIsUnavailable() { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class NoXunitTarget +{ + public void Run() + { + } +} + +[FastMoqGeneratedTestTarget(typeof(NoXunitTarget))] +public partial class GeneratedNoXunitHarness : MockerTestBase +{ +} +"; + var document = AnalyzerTestHelpers.CreateDocumentForTest( source, includeAzureFunctionsHelpers: false, @@ -299,7 +995,8 @@ private static async Task RunGeneratorAsync(string source) includeWebHelpers: false, includeDatabaseHelpers: false, includeAzureHelpers: false, - includeAggregatePackage: false); + includeAggregatePackage: false, + includeXunit: false); var compilation = await document.Project.GetCompilationAsync(); compilation.Should().NotBeNull(); @@ -307,6 +1004,181 @@ private static async Task RunGeneratorAsync(string source) [new GeneratedHarnessSourceGenerator().AsSourceGenerator()], parseOptions: (CSharpParseOptions) document.Project.ParseOptions!); driver = driver.RunGeneratorsAndUpdateCompilation(compilation!, out var outputCompilation, out var diagnostics); + + diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + outputCompilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + + var generatedSource = driver.GetRunResult().Results.SelectMany(static result => result.GeneratedSources) + .Should() + .ContainSingle() + .Subject.SourceText.ToString(); + generatedSource.Should().NotContain("global::Xunit.Fact"); + generatedSource.Should().NotContain("FastMoqGeneratedSmokeTest_"); + generatedSource.Should().NotContain("FastMoqGeneratedPlaceholder_"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldNotEmitXunitSmokeTests_WhenFrameworkPropertyIsSetToNone() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class FrameworkNoneTarget +{ + public void Run() { } +} + +[FastMoqGeneratedTestTarget(typeof(FrameworkNoneTarget))] +public partial class GeneratedFrameworkNoneHarness : MockerTestBase +{ +} +"; + + var result = await RunGeneratorAsync(source, frameworkSetting: "none"); + + result.DriverDiagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().NotContain("global::Xunit.Fact"); + generatedSource.Should().NotContain("FastMoqGeneratedSmokeTest_"); + generatedSource.Should().NotContain("FastMoqGeneratedPlaceholder_"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitXunitSmokeTests_WhenFrameworkPropertyIsExplicitlyXunit() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class FrameworkXunitTarget +{ + public void Run() { } +} + +[FastMoqGeneratedTestTarget(typeof(FrameworkXunitTarget))] +public partial class GeneratedFrameworkXunitHarness : MockerTestBase +{ +} +"; + + var result = await RunGeneratorAsync(source, frameworkSetting: "xunit"); + + result.DriverDiagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().Contain("[global::Xunit.Fact]"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_00_Component_ShouldCreateComponent"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_01_Run_ShouldExecuteWithoutThrowing"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEmitXunitSmokeTests_WhenFrameworkPropertyIsSetToNoneButCaseDiffers() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class FrameworkNoneCaseTarget +{ + public void Run() { } +} + +[FastMoqGeneratedTestTarget(typeof(FrameworkNoneCaseTarget))] +public partial class GeneratedFrameworkNoneCaseHarness : MockerTestBase +{ +} +"; + + // "None" with capital N should also disable emission (case-insensitive) + var result = await RunGeneratorAsync(source, frameworkSetting: "None"); + + result.DriverDiagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + generatedSource.Should().NotContain("global::Xunit.Fact"); + generatedSource.Should().NotContain("FastMoqGeneratedSmokeTest_"); + } + + [Fact] + public async Task GeneratedHarnessSourceGenerator_ShouldEscapeCSharpKeywordsInGeneratedInvocations() + { + const string source = @" +using FastMoq; +using FastMoq.Generators; + +namespace Demo.Tests; + +public sealed class KeywordTarget +{ + public void @class() { } + public void @interface() { } + public void @return() { } + public void NormalMethod() { } +} + +[FastMoqGeneratedTestTarget(typeof(KeywordTarget))] +public partial class GeneratedKeywordHarness : MockerTestBase +{ +} +"; + + var result = await RunGeneratorAsync(source); + + result.DriverDiagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + result.OutputCompilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject.SourceText.ToString(); + // Verify keywords are escaped in the generated invocations + generatedSource.Should().Contain("component.@class()"); + generatedSource.Should().Contain("component.@interface()"); + generatedSource.Should().Contain("component.@return()"); + generatedSource.Should().Contain("component.NormalMethod()"); + // Verify smoke test methods exist + generatedSource.Should().Contain("[global::Xunit.Fact]"); + generatedSource.Should().Contain("FastMoqGeneratedSmokeTest_"); + } + + private static async Task RunGeneratorAsync(string source, string? frameworkSetting = null) + { + var document = AnalyzerTestHelpers.CreateDocumentForTest( + source, + includeAzureFunctionsHelpers: false, + includeMoqProviderPackage: false, + includeNSubstituteProviderPackage: false, + includeWebHelpers: false, + includeDatabaseHelpers: false, + includeAzureHelpers: false, + includeAggregatePackage: false); + var compilation = await document.Project.GetCompilationAsync(); + compilation.Should().NotBeNull(); + + AnalyzerConfigOptionsProvider? optionsProvider = frameworkSetting is null + ? null + : new TestAnalyzerConfigOptionsProvider( + new global::System.Collections.Generic.Dictionary + { + ["build_property.FastMoqGeneratedTestFramework"] = frameworkSetting, + }); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [new GeneratedHarnessSourceGenerator().AsSourceGenerator()], + parseOptions: (CSharpParseOptions) document.Project.ParseOptions!, + optionsProvider: optionsProvider); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation!, out var outputCompilation, out var diagnostics); var runResult = driver.GetRunResult(); return new GeneratorTestResult( @@ -347,6 +1219,27 @@ private static T Invoke(object instance, string methodName) return (T) method!.Invoke(instance, null)!; } + private static void InvokeVoid(object instance, string methodName) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public); + method.Should().NotBeNull(); + method!.Invoke(instance, null); + } + + private static void InvokeGenericVoid(object instance, string methodName, params Type[] typeArguments) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public); + method.Should().NotBeNull(); + method!.MakeGenericMethod(typeArguments).Invoke(instance, null); + } + + private static T InvokeGeneric(object instance, string methodName, params Type[] typeArguments) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public); + method.Should().NotBeNull(); + return (T) method!.MakeGenericMethod(typeArguments).Invoke(instance, null)!; + } + private sealed class GeneratorTestResult { public GeneratorTestResult( @@ -365,5 +1258,32 @@ public GeneratorTestResult( public ImmutableArray GeneratedSources { get; } } + + private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider + { + public TestAnalyzerConfigOptionsProvider(global::System.Collections.Generic.IReadOnlyDictionary globalOptions) + { + GlobalOptions = new TestAnalyzerConfigOptions(globalOptions); + } + + public override AnalyzerConfigOptions GlobalOptions { get; } + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions; + } + + private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions + { + private readonly global::System.Collections.Generic.IReadOnlyDictionary _options; + + public TestAnalyzerConfigOptions(global::System.Collections.Generic.IReadOnlyDictionary options) + { + _options = options; + } + + public override bool TryGetValue(string key, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) + => _options.TryGetValue(key, out value); + } } } \ No newline at end of file diff --git a/FastMoq.Generators/FastMoq.Generators.csproj b/FastMoq.Generators/FastMoq.Generators.csproj index 0121f86c..e79435c2 100644 --- a/FastMoq.Generators/FastMoq.Generators.csproj +++ b/FastMoq.Generators/FastMoq.Generators.csproj @@ -21,6 +21,10 @@ + + + + all diff --git a/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs b/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs index a6a026ad..6273020d 100644 --- a/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs +++ b/FastMoq.Generators/GeneratedHarnessSourceGenerator.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Text; @@ -12,9 +13,20 @@ namespace FastMoq.Generators public sealed class GeneratedHarnessSourceGenerator : IIncrementalGenerator { internal const string GeneratedTestTargetAttributeMetadataName = "FastMoq.Generators.FastMoqGeneratedTestTargetAttribute"; + private const string XUnitFactAttributeMetadataName = "Xunit.FactAttribute"; + private const string FastMoqGeneratedTestFrameworkPropertyName = "build_property.FastMoqGeneratedTestFramework"; + private const string FrameworkSettingNone = "none"; private const string ComponentConstructorParameterTypesPropertyName = "ComponentConstructorParameterTypes"; + private const string SetupMocksActionPropertyName = "SetupMocksAction"; + private const string CreatedComponentActionPropertyName = "CreatedComponentAction"; + private const string ConfigureMockerPolicyPropertyName = "ConfigureMockerPolicy"; private const string MockerTestBaseTypeName = "MockerTestBase`1"; private const string MockerTestBaseNamespace = "FastMoq"; + private const string ThreadingTasksNamespace = "System.Threading.Tasks"; + private const string TaskMetadataName = "Task"; + private const string GenericTaskMetadataName = "Task`1"; + private const string ValueTaskMetadataName = "ValueTask"; + private const string GenericValueTaskMetadataName = "ValueTask`1"; public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -25,8 +37,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static (generatorContext, _) => TryCreateTargetModel(generatorContext)) .Where(static model => model is not null); - context.RegisterSourceOutput(targets, static (productionContext, model) => - EmitSource(productionContext, model!)); + var frameworkSetting = context.AnalyzerConfigOptionsProvider + .Select(static (options, _) => + { + options.GlobalOptions.TryGetValue(FastMoqGeneratedTestFrameworkPropertyName, out var value); + return value?.Trim() ?? string.Empty; + }); + + context.RegisterSourceOutput( + targets.Combine(frameworkSetting), + static (productionContext, pair) => EmitSource(productionContext, pair.Left!, pair.Right)); } private static GeneratedHarnessTargetModel? TryCreateTargetModel(GeneratorAttributeSyntaxContext context) @@ -38,9 +58,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } var targetType = (INamedTypeSymbol)context.TargetSymbol; + var propertyNames = new global::System.Collections.Generic.HashSet( + targetType.GetMembers().OfType().Select(static property => property.Name)); if (targetType.Arity != 0 || targetType.ContainingType is not null || - targetType.GetMembers().OfType().Any(static property => property.Name == ComponentConstructorParameterTypesPropertyName)) + propertyNames.Contains(ComponentConstructorParameterTypesPropertyName)) { return null; } @@ -71,6 +93,11 @@ attribute.ConstructorArguments[0].Value is not ITypeSymbol attributeComponentTyp : targetType.ContainingNamespace.ToDisplayString(), targetType.Name, componentType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + context.SemanticModel.Compilation.GetTypeByMetadataName(XUnitFactAttributeMetadataName) is not null, + GetGeneratedTestMethods(componentType), + !propertyNames.Contains(SetupMocksActionPropertyName), + !propertyNames.Contains(CreatedComponentActionPropertyName), + !propertyNames.Contains(ConfigureMockerPolicyPropertyName), selectedConstructor!.Parameters .Select(parameter => parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) .ToImmutableArray(), @@ -118,6 +145,305 @@ private static ImmutableArray GetExplicitConstructorParameterTypes( return builder.ToImmutable(); } + private static ImmutableArray GetGeneratedTestMethods(INamedTypeSymbol componentType) + { + var candidateMethods = componentType.GetMembers() + .OfType() + .Where(static method => + method.MethodKind == MethodKind.Ordinary && + method.DeclaredAccessibility == Accessibility.Public && + !method.IsStatic && + !method.IsImplicitlyDeclared && + !IsObjectOverride(method)) + .OrderBy(static method => method.Name, StringComparer.Ordinal) + .ThenBy(static method => method.Parameters.Length) + .ThenBy(static method => method.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), StringComparer.Ordinal); + + var builder = ImmutableArray.CreateBuilder(); + var ordinal = 1; + foreach (var method in candidateMethods) + { + builder.Add(CreateGeneratedTestMethodModel(method, ordinal)); + ordinal++; + } + + return builder.ToImmutable(); + } + + private static bool IsObjectOverride(IMethodSymbol method) + { + return method.IsOverride && + method.OverriddenMethod?.ContainingType.SpecialType == SpecialType.System_Object; + } + private static string EscapeIdentifierIfKeyword(string identifier) + { + var keywordKind = SyntaxFacts.GetKeywordKind(identifier); + return keywordKind != SyntaxKind.None ? "@" + identifier : identifier; + } + private static GeneratedComponentTestMethodModel CreateGeneratedTestMethodModel(IMethodSymbol method, int ordinal) + { + var methodDisplayName = method.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + var escapedMethodName = EscapeIdentifierIfKeyword(method.Name); + var methodIdentifier = CreateGeneratedMethodIdentifier(method.Name); + + if (method.IsGenericMethod) + { + return GeneratedComponentTestMethodModel.CreateDeferred(escapedMethodName, ordinal, methodIdentifier, methodDisplayName, "is generic."); + } + + if (!TryCreateInvocationArguments(method, out var invocationArguments, out var deferredReasonSuffix)) + { + return GeneratedComponentTestMethodModel.CreateDeferred(escapedMethodName, ordinal, methodIdentifier, methodDisplayName, deferredReasonSuffix); + } + + if (method.ReturnsByRef || method.ReturnsByRefReadonly) + { + return GeneratedComponentTestMethodModel.CreateDeferred(escapedMethodName, ordinal, methodIdentifier, methodDisplayName, "returns by reference."); + } + + if (TryGetUnsupportedReturnTypeReason(method.ReturnType, out var unsupportedReason)) + { + return GeneratedComponentTestMethodModel.CreateDeferred(escapedMethodName, ordinal, methodIdentifier, methodDisplayName, unsupportedReason); + } + + return GeneratedComponentTestMethodModel.CreateSupported( + escapedMethodName, + ordinal, + methodIdentifier, + invocationArguments, + IsAsyncReturnType(method.ReturnType), + ReturnsValue(method.ReturnType)); + } + + private static bool TryCreateInvocationArguments(IMethodSymbol method, out string invocationArguments, out string deferredReasonSuffix) + { + if (method.Parameters.Length == 0) + { + invocationArguments = string.Empty; + deferredReasonSuffix = string.Empty; + return true; + } + + var argumentBuilder = ImmutableArray.CreateBuilder(method.Parameters.Length); + foreach (var parameter in method.Parameters) + { + if (parameter.RefKind != RefKind.None) + { + invocationArguments = string.Empty; + deferredReasonSuffix = "uses ref, in, or out parameters."; + return false; + } + + if (!parameter.IsOptional) + { + invocationArguments = string.Empty; + deferredReasonSuffix = "requires non-optional parameters."; + return false; + } + + if (!TryCreateDefaultArgumentExpression(parameter, out var argumentExpression)) + { + invocationArguments = string.Empty; + deferredReasonSuffix = "has an unsupported optional-parameter default for '" + parameter.Name + "'."; + return false; + } + + argumentBuilder.Add(argumentExpression); + } + + invocationArguments = string.Join(", ", argumentBuilder); + deferredReasonSuffix = string.Empty; + return true; + } + + private static bool TryCreateDefaultArgumentExpression(IParameterSymbol parameter, out string argumentExpression) + { + if (!parameter.HasExplicitDefaultValue) + { + argumentExpression = string.Empty; + return false; + } + + if (TryFormatDefaultValue(parameter.Type, parameter.ExplicitDefaultValue, out var rawArgumentExpression)) + { + argumentExpression = CreateTypedArgumentExpression(parameter.Type, rawArgumentExpression); + return true; + } + + argumentExpression = string.Empty; + return false; + } + + private static string CreateTypedArgumentExpression(ITypeSymbol parameterType, string rawArgumentExpression) + { + return "(" + parameterType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ")" + rawArgumentExpression; + } + + private static bool TryFormatDefaultValue(ITypeSymbol parameterType, object? explicitDefaultValue, out string expression) + { + if (explicitDefaultValue is null) + { + if (parameterType.IsReferenceType || IsNullableValueType(parameterType)) + { + expression = "null"; + return true; + } + + expression = string.Empty; + return false; + } + + if (parameterType.TypeKind == TypeKind.Enum && + TryFormatEnumDefaultValue(parameterType, explicitDefaultValue, out expression)) + { + return true; + } + + switch (explicitDefaultValue) + { + case bool booleanValue: + expression = booleanValue ? "true" : "false"; + return true; + case char charValue: + expression = SymbolDisplay.FormatLiteral(charValue, quote: true); + return true; + case string stringValue: + expression = SymbolDisplay.FormatLiteral(stringValue, quote: true); + return true; + case sbyte sbyteValue: + expression = sbyteValue.ToString(CultureInfo.InvariantCulture); + return true; + case byte byteValue: + expression = byteValue.ToString(CultureInfo.InvariantCulture); + return true; + case short shortValue: + expression = shortValue.ToString(CultureInfo.InvariantCulture); + return true; + case ushort ushortValue: + expression = ushortValue.ToString(CultureInfo.InvariantCulture); + return true; + case int intValue: + expression = intValue.ToString(CultureInfo.InvariantCulture); + return true; + case uint uintValue: + expression = uintValue.ToString(CultureInfo.InvariantCulture) + "U"; + return true; + case long longValue: + expression = longValue.ToString(CultureInfo.InvariantCulture) + "L"; + return true; + case ulong ulongValue: + expression = ulongValue.ToString(CultureInfo.InvariantCulture) + "UL"; + return true; + case float floatValue: + expression = floatValue.ToString("R", CultureInfo.InvariantCulture) + "F"; + return true; + case double doubleValue: + expression = doubleValue.ToString("R", CultureInfo.InvariantCulture); + return true; + case decimal decimalValue: + expression = decimalValue.ToString(CultureInfo.InvariantCulture) + "M"; + return true; + default: + expression = string.Empty; + return false; + } + } + + private static bool TryFormatEnumDefaultValue(ITypeSymbol parameterType, object explicitDefaultValue, out string expression) + { + if (parameterType is not INamedTypeSymbol namedEnumType) + { + expression = string.Empty; + return false; + } + + foreach (var field in namedEnumType.GetMembers().OfType()) + { + if (!field.HasConstantValue) + { + continue; + } + + if (Equals(field.ConstantValue, explicitDefaultValue)) + { + expression = namedEnumType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "." + field.Name; + return true; + } + } + + expression = string.Empty; + return false; + } + + private static bool IsNullableValueType(ITypeSymbol typeSymbol) + { + return typeSymbol is INamedTypeSymbol namedType && + namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + } + + private static string CreateGeneratedMethodIdentifier(string methodName) + { + return new string(methodName.Select(static character => + char.IsLetterOrDigit(character) || character == '_' + ? character + : '_').ToArray()); + } + + private static bool TryGetUnsupportedReturnTypeReason(ITypeSymbol returnType, out string reason) + { + if (returnType.TypeKind == TypeKind.Pointer || returnType.TypeKind == TypeKind.FunctionPointer) + { + reason = "has unsupported return type '" + returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "'."; + return true; + } + + reason = string.Empty; + return false; + } + + private static bool IsAsyncReturnType(ITypeSymbol returnType) + { + return IsNonGenericTaskReturnType(returnType) || + IsGenericTaskReturnType(returnType) || + IsNonGenericValueTaskReturnType(returnType) || + IsGenericValueTaskReturnType(returnType); + } + + private static bool ReturnsValue(ITypeSymbol returnType) + { + return returnType.SpecialType != SpecialType.System_Void && + !IsNonGenericTaskReturnType(returnType) && + !IsNonGenericValueTaskReturnType(returnType); + } + + private static bool IsNonGenericTaskReturnType(ITypeSymbol returnType) + { + return returnType is INamedTypeSymbol namedType && + string.Equals(namedType.ContainingNamespace.ToDisplayString(), ThreadingTasksNamespace, StringComparison.Ordinal) && + string.Equals(namedType.MetadataName, TaskMetadataName, StringComparison.Ordinal); + } + + private static bool IsGenericTaskReturnType(ITypeSymbol returnType) + { + return returnType is INamedTypeSymbol namedType && + string.Equals(namedType.ContainingNamespace.ToDisplayString(), ThreadingTasksNamespace, StringComparison.Ordinal) && + string.Equals(namedType.MetadataName, GenericTaskMetadataName, StringComparison.Ordinal); + } + + private static bool IsNonGenericValueTaskReturnType(ITypeSymbol returnType) + { + return returnType is INamedTypeSymbol namedType && + string.Equals(namedType.ContainingNamespace.ToDisplayString(), ThreadingTasksNamespace, StringComparison.Ordinal) && + string.Equals(namedType.MetadataName, ValueTaskMetadataName, StringComparison.Ordinal); + } + + private static bool IsGenericValueTaskReturnType(ITypeSymbol returnType) + { + return returnType is INamedTypeSymbol namedType && + string.Equals(namedType.ContainingNamespace.ToDisplayString(), ThreadingTasksNamespace, StringComparison.Ordinal) && + string.Equals(namedType.MetadataName, GenericValueTaskMetadataName, StringComparison.Ordinal); + } + private static bool TryResolveConstructor( INamedTypeSymbol componentType, ImmutableArray explicitConstructorParameterTypes, @@ -165,8 +491,11 @@ private static bool ParametersMatch(IMethodSymbol constructor, ImmutableArray"); sourceBuilder.AppendLine("#nullable enable"); @@ -184,6 +513,114 @@ private static void EmitSource(SourceProductionContext context, GeneratedHarness AppendIndentedLine(sourceBuilder, 2, "protected override global::System.Type?[]? ComponentConstructorParameterTypes =>"); AppendIndentedLine(sourceBuilder, 3, "FastMoqGeneratedHarnessMetadata.ConstructorParameterTypes;"); sourceBuilder.AppendLine(); + if (target.EmitConfigureMockerPolicyOverride) + { + AppendIndentedLine(sourceBuilder, 2, "protected override global::System.Action? ConfigureMockerPolicy =>"); + AppendIndentedLine(sourceBuilder, 3, "options =>"); + AppendIndentedLine(sourceBuilder, 3, "{"); + AppendIndentedLine(sourceBuilder, 4, "base.ConfigureMockerPolicy?.Invoke(options);"); + AppendIndentedLine(sourceBuilder, 4, "ConfigureGeneratedMockerPolicy(options);"); + AppendIndentedLine(sourceBuilder, 3, "};"); + sourceBuilder.AppendLine(); + AppendIndentedLine(sourceBuilder, 2, "partial void ConfigureGeneratedMockerPolicy(global::FastMoq.MockerPolicyOptions options);"); + sourceBuilder.AppendLine(); + } + + if (target.EmitSetupMocksActionOverride) + { + AppendIndentedLine(sourceBuilder, 2, "protected override global::System.Action? SetupMocksAction =>"); + AppendIndentedLine(sourceBuilder, 3, "mocker =>"); + AppendIndentedLine(sourceBuilder, 3, "{"); + AppendIndentedLine(sourceBuilder, 4, "base.SetupMocksAction?.Invoke(mocker);"); + AppendIndentedLine(sourceBuilder, 4, "ConfigureGeneratedMocks(mocker);"); + AppendIndentedLine(sourceBuilder, 3, "};"); + sourceBuilder.AppendLine(); + AppendIndentedLine(sourceBuilder, 2, "partial void ConfigureGeneratedMocks(global::FastMoq.Mocker mocker);"); + sourceBuilder.AppendLine(); + } + + if (target.EmitCreatedComponentActionOverride) + { + AppendIndentedLine(sourceBuilder, 2, $"protected override global::System.Action<{target.ComponentTypeName}>? CreatedComponentAction =>"); + AppendIndentedLine(sourceBuilder, 3, "component =>"); + AppendIndentedLine(sourceBuilder, 3, "{"); + AppendIndentedLine(sourceBuilder, 4, "base.CreatedComponentAction?.Invoke(component);"); + AppendIndentedLine(sourceBuilder, 4, "AfterGeneratedComponentCreated(component);"); + AppendIndentedLine(sourceBuilder, 3, "};"); + sourceBuilder.AppendLine(); + AppendIndentedLine(sourceBuilder, 2, $"partial void AfterGeneratedComponentCreated({target.ComponentTypeName} component);"); + sourceBuilder.AppendLine(); + } + + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Executes the generated arrange, act, assert, and verify scaffold synchronously."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "public void ExecuteGeneratedScenarioScaffold() => CreateGeneratedScenarioScaffold().Execute();"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Executes the generated arrange, act, assert, and verify scaffold asynchronously."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// A task that completes when the generated scaffold finishes running."); + AppendIndentedLine(sourceBuilder, 2, "public global::System.Threading.Tasks.Task ExecuteGeneratedScenarioScaffoldAsync() => CreateGeneratedScenarioScaffold().ExecuteAsync();"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Executes the generated scaffold with an act phase that expects the specified exception type."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The exception type expected from the generated act phase."); + AppendIndentedLine(sourceBuilder, 2, "public void ExecuteGeneratedExpectedExceptionScenarioScaffold() where TException : global::System.Exception => CreateGeneratedExpectedExceptionScenarioScaffold().Execute();"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Executes the generated scaffold asynchronously with an act phase that expects the specified exception type."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The exception type expected from the generated act phase."); + AppendIndentedLine(sourceBuilder, 2, "/// A task that completes when the generated scaffold finishes running."); + AppendIndentedLine(sourceBuilder, 2, "public global::System.Threading.Tasks.Task ExecuteGeneratedExpectedExceptionScenarioScaffoldAsync() where TException : global::System.Exception => CreateGeneratedExpectedExceptionScenarioScaffold().ExecuteAsync();"); + sourceBuilder.AppendLine(); + AppendIndentedLine(sourceBuilder, 2, $"private global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> CreateGeneratedScenarioScaffold()"); + AppendIndentedLine(sourceBuilder, 2, "{"); + AppendIndentedLine(sourceBuilder, 3, "var scenario = Scenario;"); + AppendIndentedLine(sourceBuilder, 3, "ArrangeGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "ActGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "AssertGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "VerifyGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "return scenario;"); + AppendIndentedLine(sourceBuilder, 2, "}"); + sourceBuilder.AppendLine(); + AppendIndentedLine(sourceBuilder, 2, $"private global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> CreateGeneratedExpectedExceptionScenarioScaffold() where TException : global::System.Exception"); + AppendIndentedLine(sourceBuilder, 2, "{"); + AppendIndentedLine(sourceBuilder, 3, "var scenario = Scenario;"); + AppendIndentedLine(sourceBuilder, 3, "ArrangeGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "ExpectedExceptionGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "AssertGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "VerifyGeneratedScenario(scenario);"); + AppendIndentedLine(sourceBuilder, 3, "return scenario;"); + AppendIndentedLine(sourceBuilder, 2, "}"); + sourceBuilder.AppendLine(); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Adds arrange steps to the generated scenario scaffold."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The scenario builder to configure."); + AppendIndentedLine(sourceBuilder, 2, $"partial void ArrangeGeneratedScenario(global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> scenario);"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Adds act steps to the generated scenario scaffold."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The scenario builder to configure."); + AppendIndentedLine(sourceBuilder, 2, $"partial void ActGeneratedScenario(global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> scenario);"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Adds an act step to the generated scaffold that expects the specified exception type."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The exception type expected from the generated act phase."); + AppendIndentedLine(sourceBuilder, 2, "/// The scenario builder to configure."); + AppendIndentedLine(sourceBuilder, 2, $"partial void ExpectedExceptionGeneratedScenario(global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> scenario) where TException : global::System.Exception;"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Adds assertion steps to the generated scenario scaffold."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The scenario builder to configure."); + AppendIndentedLine(sourceBuilder, 2, $"partial void AssertGeneratedScenario(global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> scenario);"); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// Adds verification steps to the generated scenario scaffold."); + AppendIndentedLine(sourceBuilder, 2, "/// "); + AppendIndentedLine(sourceBuilder, 2, "/// The scenario builder to configure."); + AppendIndentedLine(sourceBuilder, 2, $"partial void VerifyGeneratedScenario(global::FastMoq.ScenarioBuilder<{target.ComponentTypeName}> scenario);"); + sourceBuilder.AppendLine(); + AppendGeneratedXUnitSmokeTests(sourceBuilder, target, emitXUnitSmokeTests); AppendIndentedLine(sourceBuilder, 2, "internal static class FastMoqGeneratedHarnessMetadata"); AppendIndentedLine(sourceBuilder, 2, "{"); AppendIndentedLine(sourceBuilder, 3, $"internal static global::System.Type ComponentType {{ get; }} = typeof({target.ComponentTypeName});"); @@ -221,6 +658,72 @@ private static void AppendIndentedLine(StringBuilder builder, int indentLevel, s .AppendLine(text); } + private static void AppendGeneratedXUnitSmokeTests(StringBuilder sourceBuilder, GeneratedHarnessTargetModel target, bool emitXUnitSmokeTests) + { + if (!emitXUnitSmokeTests) + { + return; + } + + AppendIndentedLine(sourceBuilder, 2, "[global::Xunit.Fact]"); + AppendIndentedLine(sourceBuilder, 2, "public void FastMoqGeneratedSmokeTest_00_Component_ShouldCreateComponent()"); + AppendIndentedLine(sourceBuilder, 2, "{"); + AppendIndentedLine(sourceBuilder, 3, "_ = Component;"); + AppendIndentedLine(sourceBuilder, 2, "}"); + sourceBuilder.AppendLine(); + + foreach (var generatedTestMethod in target.GeneratedTestMethods) + { + if (generatedTestMethod.IsDeferred) + { + var skipReasonLiteral = SymbolDisplay.FormatLiteral(generatedTestMethod.DeferredReason!, quote: true); + AppendIndentedLine(sourceBuilder, 2, "[global::Xunit.Fact(Skip = " + skipReasonLiteral + ")]"); + AppendIndentedLine(sourceBuilder, 2, "public void " + generatedTestMethod.GeneratedMethodName + "()"); + AppendIndentedLine(sourceBuilder, 2, "{"); + AppendIndentedLine(sourceBuilder, 3, "throw new global::System.NotSupportedException(" + skipReasonLiteral + ");"); + AppendIndentedLine(sourceBuilder, 2, "}"); + sourceBuilder.AppendLine(); + continue; + } + + AppendIndentedLine(sourceBuilder, 2, "[global::Xunit.Fact]"); + if (generatedTestMethod.IsAsync) + { + AppendIndentedLine(sourceBuilder, 2, "public async global::System.Threading.Tasks.Task " + generatedTestMethod.GeneratedMethodName + "()"); + } + else + { + AppendIndentedLine(sourceBuilder, 2, "public void " + generatedTestMethod.GeneratedMethodName + "()"); + } + + AppendIndentedLine(sourceBuilder, 2, "{"); + AppendIndentedLine(sourceBuilder, 3, "var component = Component;"); + + if (generatedTestMethod.IsAsync) + { + if (generatedTestMethod.ReturnsValue) + { + AppendIndentedLine(sourceBuilder, 3, "_ = await component." + generatedTestMethod.ComponentMethodName + "(" + generatedTestMethod.InvocationArguments + ");"); + } + else + { + AppendIndentedLine(sourceBuilder, 3, "await component." + generatedTestMethod.ComponentMethodName + "(" + generatedTestMethod.InvocationArguments + ");"); + } + } + else if (generatedTestMethod.ReturnsValue) + { + AppendIndentedLine(sourceBuilder, 3, "_ = component." + generatedTestMethod.ComponentMethodName + "(" + generatedTestMethod.InvocationArguments + ");"); + } + else + { + AppendIndentedLine(sourceBuilder, 3, "component." + generatedTestMethod.ComponentMethodName + "(" + generatedTestMethod.InvocationArguments + ");"); + } + + AppendIndentedLine(sourceBuilder, 2, "}"); + sourceBuilder.AppendLine(); + } + } + private static string GetHintName(GeneratedHarnessTargetModel target) { var identifier = string.IsNullOrWhiteSpace(target.NamespaceName) @@ -239,12 +742,22 @@ public GeneratedHarnessTargetModel( string? namespaceName, string targetTypeName, string componentTypeName, + bool emitXUnitSmokeTests, + ImmutableArray generatedTestMethods, + bool emitSetupMocksActionOverride, + bool emitCreatedComponentActionOverride, + bool emitConfigureMockerPolicyOverride, ImmutableArray constructorParameterTypeNames, ImmutableArray dependencyNames) { NamespaceName = namespaceName; TargetTypeName = targetTypeName; ComponentTypeName = componentTypeName; + EmitXUnitSmokeTests = emitXUnitSmokeTests; + GeneratedTestMethods = generatedTestMethods; + EmitSetupMocksActionOverride = emitSetupMocksActionOverride; + EmitCreatedComponentActionOverride = emitCreatedComponentActionOverride; + EmitConfigureMockerPolicyOverride = emitConfigureMockerPolicyOverride; ConstructorParameterTypeNames = constructorParameterTypeNames; DependencyNames = dependencyNames; } @@ -255,9 +768,90 @@ public GeneratedHarnessTargetModel( public string ComponentTypeName { get; } + public bool EmitXUnitSmokeTests { get; } + + public ImmutableArray GeneratedTestMethods { get; } + + public bool EmitSetupMocksActionOverride { get; } + + public bool EmitCreatedComponentActionOverride { get; } + + public bool EmitConfigureMockerPolicyOverride { get; } + public ImmutableArray ConstructorParameterTypeNames { get; } public ImmutableArray DependencyNames { get; } } + + private sealed class GeneratedComponentTestMethodModel + { + private GeneratedComponentTestMethodModel( + string componentMethodName, + string generatedMethodName, + string invocationArguments, + bool isDeferred, + string? deferredReason, + bool isAsync, + bool returnsValue) + { + ComponentMethodName = componentMethodName; + GeneratedMethodName = generatedMethodName; + InvocationArguments = invocationArguments; + IsDeferred = isDeferred; + DeferredReason = deferredReason; + IsAsync = isAsync; + ReturnsValue = returnsValue; + } + + public string ComponentMethodName { get; } + + public string GeneratedMethodName { get; } + + public string InvocationArguments { get; } + + public bool IsDeferred { get; } + + public string? DeferredReason { get; } + + public bool IsAsync { get; } + + public bool ReturnsValue { get; } + + public static GeneratedComponentTestMethodModel CreateSupported( + string componentMethodName, + int ordinal, + string methodIdentifier, + string invocationArguments, + bool isAsync, + bool returnsValue) + { + return new GeneratedComponentTestMethodModel( + componentMethodName, + "FastMoqGeneratedSmokeTest_" + ordinal.ToString("D2") + "_" + methodIdentifier + "_ShouldExecuteWithoutThrowing", + invocationArguments, + isDeferred: false, + deferredReason: null, + isAsync: isAsync, + returnsValue: returnsValue); + } + + public static GeneratedComponentTestMethodModel CreateDeferred( + string componentMethodName, + int ordinal, + string methodIdentifier, + string methodDisplayName, + string deferredReasonSuffix) + { + var deferredReason = "FastMoq generated smoke test deferred: method '" + methodDisplayName + "' " + deferredReasonSuffix; + return new GeneratedComponentTestMethodModel( + componentMethodName, + "FastMoqGeneratedPlaceholder_" + ordinal.ToString("D2") + "_" + methodIdentifier + "_IsDeferred", + string.Empty, + isDeferred: true, + deferredReason, + isAsync: false, + returnsValue: false); + } + } } } \ No newline at end of file diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 56d7f7c9..19103642 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -118,28 +118,24 @@ Current public issue anchor in this bucket is [#141](https://github.com/cwinland ### Code generation and scaffolding -FastMoq now contains a narrow first-party source-generator slice for explicit harness targets, but the broader code-generation work is still part of the current v5 direction. +Code generation remains part of the current v5 direction. The main value is not "generated mocks" in isolation. The stronger FastMoq-specific opportunity is compile-time provider-first test generation: generated test graphs, harness scaffolding, and framework-helper builders that reduce reflection, reduce boilerplate, and stay aligned with FastMoq-owned APIs. -For the detailed issue crosswalk and implementation planning, see [Generator roadmap and design](./generator-roadmap.md). -For the current contract-focused design docs, see [Generated test settings design](./generated-test-settings.md), [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md), and [Generated helper family matrix](./generated-helper-family-matrix.md). - -Planned direction stays phased: +Current direction stays phased: 1. Compile-time test graph and harness generation. 2. Shared generated-test settings and test-platform contracts. 3. Stable scenario-scaffolding contracts and helper-boundary narrowing. -4. Scenario and suite scaffolding implementation. -5. Full generated tests and analyzer-guided generation once the contract and scaffold layers are stable. +4. Scenario and suite scaffolding. +5. Broaden full generated tests and analyzer-guided generation beyond the current explicit-harness xUnit smoke-test slice. 6. Framework-helper builders for repeated helper-heavy test patterns when they justify a separate layer. 7. Provider-optimized or narrower generated-fake evaluation only after the shared contract is stable. -The current implementation-facing MVP remains intentionally narrow: generated graph metadata and harness bootstrap are in place, while broader generated-test settings, scenario contracts, helper-boundary narrowing, scenario scaffolding, and full generated tests continue through the linked design docs and the detailed generator roadmap. - -For the current detailed direction, design constraints, and fuller generator issue mapping, see [Generator roadmap and design](./generator-roadmap.md). +For the current detailed direction, implementation status, scope boundaries, and fuller generator issue mapping, see [Generator roadmap and design](./generator-roadmap.md). For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). +For the current helper-boundary contract behind [#134](https://github.com/cwinland/FastMoq/issues/134), see [Generated helper family matrix](./generated-helper-family-matrix.md). ### `MockOptional` retirement diff --git a/docs/roadmap/generated-helper-family-matrix.md b/docs/roadmap/generated-helper-family-matrix.md index 0eda35b3..d6c00a3c 100644 --- a/docs/roadmap/generated-helper-family-matrix.md +++ b/docs/roadmap/generated-helper-family-matrix.md @@ -2,7 +2,7 @@ This page captures the current helper-family narrowing contract for generator-facing FastMoq helper surfaces. It is the canonical repo-local design artifact for [#134](https://github.com/cwinland/FastMoq/issues/134), not a shipped feature guide. -This page is intentionally docs-only. It does not imply that FastMoq currently generates helper-heavy test scaffolds, and it does not itself implement helper normalization. +This page is intentionally docs-only. The repo does emit core scenario and suite scaffolding, but it does not yet emit helper-heavy or package-specific scaffold variants, and this page does not itself implement helper normalization. For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). @@ -32,6 +32,7 @@ This slice does not: The first generated scenario or suite scaffold implementation should use this matrix as a hard boundary. +- the current supported `#136` scaffold slice stays inside core provider-neutral scenario and suite hooks and does not yet auto-emit helper-family-specific setup from this matrix - `#136` may target helper families classified as `generator-stable as-is` directly. - `#136` may target helper families classified as `generator-stable with explicit bounds` only when the documented package or provider conditions are satisfied. - `#136` should not target helper families classified as `deferred from first scenario scaffolds`. @@ -102,6 +103,6 @@ Open or route to a later issue when one of these becomes necessary: The immediate follow-on order after this docs pass should remain explicit: - `#134` ends once the helper-family matrix, package or provider bounds, and split criteria are documented and mirrored into the roadmap. -- `#136` should then implement generated scenario and suite scaffolding against the `#126` scenario contract, this helper-family matrix, and the `#162` settings contract. +- the first `#136` implementation now emits generated scenario and suite scaffolding inside explicit partial harness targets while staying within the core contract and these matrix bounds. - `#123` and `#124` should consume the same helper assumptions later instead of inventing local defaults or re-triaging helper families inside their own slices. - `#137` remains later and conditional rather than part of the immediate `#126 -> #134 -> #136 -> #123/#124` chain. diff --git a/docs/roadmap/generated-scenario-scaffolding-contract.md b/docs/roadmap/generated-scenario-scaffolding-contract.md index 6673cba1..860257d0 100644 --- a/docs/roadmap/generated-scenario-scaffolding-contract.md +++ b/docs/roadmap/generated-scenario-scaffolding-contract.md @@ -1,11 +1,20 @@ # Generated Scenario Scaffolding Contract -This page captures the current design contract for generator-stable scenario and suite scaffolding in FastMoq. It is the canonical repo-local design artifact for [#126](https://github.com/cwinland/FastMoq/issues/126), not a shipped feature guide. +This page captures the current design contract for generator-stable scenario and suite scaffolding in FastMoq. It is the canonical repo-local design artifact for [#126](https://github.com/cwinland/FastMoq/issues/126), and the contract described here now has a narrow implementation in the repo. -This page is intentionally docs and API-design only. It does not imply that FastMoq currently emits generated scenario scaffolds. +This page remains primarily a contract and API-design artifact, but the repo now emits a narrow first scenario/scaffold implementation for explicit partial `MockerTestBase` targets. The supported and deferred shapes below describe that current implementation boundary. For the shared generated-test settings and extensibility contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). +## Current Supported Scope + +The current supported [#136](https://github.com/cwinland/FastMoq/issues/136) scaffold slice is narrow: + +- generated scenario entry points are emitted inside explicit partial `MockerTestBase` harness targets +- generated suite scaffolding means shared setup composition inside those harnesses through `ConfigureGeneratedMockerPolicy`, `ConfigureGeneratedMocks`, and `AfterGeneratedComponentCreated` +- generated sync, async, and continued-assertion expected-exception entry points build on the existing `ScenarioBuilder` pipeline +- full generated test classes, standalone generated suite types, and consumer-configurable settings-driven scaffold shapes remain deferred + ## Purpose And Non-Goals This slice exists to define the stable runtime contract that later generated scenario scaffolds should target. @@ -44,10 +53,12 @@ Primary anchors: ## First Supported Scaffold Shape -The first supported scaffold shape should stay narrow and method-centric: +The current supported scaffold shape stays narrow and method-centric: - generated scenario methods live inside generated partial `MockerTestBase`-based test types +- generated suite scaffolding for [#136](https://github.com/cwinland/FastMoq/issues/136) means suite-level shared setup regions and post-creation hooks inside the same generated partial harness, not standalone scenario or suite container types - generated scenario methods build on the existing `Scenario` property or `Mocks.Scenario(Component)` flow instead of creating a new standalone scenario-class abstraction +- current generated entry points cover sync, async, and continued-assertion expected-exception flows - generated scenario methods may consult `GetComponentConstructionPlan()` or `GetComponentHarnessBootstrapDescriptor()` when constructor or bootstrap metadata needs to shape the scaffold, but the scenario contract itself stays above the graph and harness layer This keeps the first contract aligned with the current runtime model and avoids creating a second compatibility-heavy scenario surface beside `ScenarioBuilder`. @@ -72,6 +83,8 @@ The scenario contract must match the first extensibility direction already settl - the first hook-emission model stays constrained to `GeneratedHookEmissionStyle.None` and `GeneratedHookEmissionStyle.CompanionPartialHooks` - `#126` defines the scenario hook roles that later scaffolds need; `#136` chooses the concrete emitted members that realize those roles while preserving regeneration safety +The current implementation materializes those roles through companion partial members named `ConfigureGeneratedMockerPolicy`, `ConfigureGeneratedMocks`, `AfterGeneratedComponentCreated`, `ArrangeGeneratedScenario`, `ActGeneratedScenario`, `ExpectedExceptionGeneratedScenario`, `AssertGeneratedScenario`, and `VerifyGeneratedScenario`. + The first minimal hook-role set should stay explicit: - `Arrange` @@ -102,6 +115,8 @@ Key rules: - `WhenThrows(...)` is the expected-exception path when the act phase should fail but trailing assertions should still run - `ExecuteThrows()` and `ExecuteThrowsAsync()` are the path when the exception object itself is the main assertion target - sync versus async generated test-method syntax belongs to `#162` settings consumption and `#136` scaffold implementation, not to a separate `#126` runtime model +- the current generated scaffold implementation emits `ExecuteGeneratedExpectedExceptionScenarioScaffold()` and `ExecuteGeneratedExpectedExceptionScenarioScaffoldAsync()` for the continued-assertion `WhenThrows(...)` path +- generated wrappers for direct `ExecuteThrows()` and `ExecuteThrowsAsync()` exception-object inspection remain deferred; use a hand-written `Scenario` flow when the exception object itself is the primary assertion target ## Composition With Existing Contracts @@ -122,10 +137,11 @@ In practice that means: The following work stays outside `#126` even when it is closely related: - helper-family narrowing and re-triage across logging, HTTP, Azure, Azure Functions, and typed DI stays in [#134](https://github.com/cwinland/FastMoq/issues/134) and is documented in [Generated helper family matrix](./generated-helper-family-matrix.md) -- concrete generated scenario and suite scaffolding implementation stays in [#136](https://github.com/cwinland/FastMoq/issues/136) +- the first concrete generated scenario and suite scaffolding implementation now lives in [#136](https://github.com/cwinland/FastMoq/issues/136) - full generated tests from existing services and supported classes stays in [#123](https://github.com/cwinland/FastMoq/issues/123) - analyzer-guided generation routing and missing-package suggestions stays in [#124](https://github.com/cwinland/FastMoq/issues/124) - helper-builder generation stays in [#137](https://github.com/cwinland/FastMoq/issues/137) +- standalone generated suite types, settings-driven scaffold variants, and direct exception-object-returning generated scaffold wrappers remain explicit deferred cases from the first `#136` slice If later discovery shows that one truly missing runtime seam blocks the contract, track that seam narrowly instead of silently widening `#126` into a broad implementation branch. @@ -133,8 +149,8 @@ If later discovery shows that one truly missing runtime seam blocks the contract The immediate sequence after `#162` should stay explicit: -- `#126` is the next design slice and defines the stable scenario-scaffolding contract -- `#134` is the next narrowing and re-triage pass that classifies which helper families the scenario implementation may later depend on -- `#136` is the next implementation-facing PR once the scenario contract and helper boundaries are explicit +- `#126` defines the stable scenario-scaffolding contract +- `#134` classifies which helper families the scenario implementation may later depend on +- `#136` is the current narrow implementation slice: generated scenario execution plus suite-level shared setup inside explicit partial harness targets - `#123` and `#124` follow only after the contract and scaffold layers are stable enough to consume instead of re-deriving local defaults - `#137` remains later and conditional rather than part of the immediate next-step chain diff --git a/docs/roadmap/generated-test-settings.md b/docs/roadmap/generated-test-settings.md index 32f6fa71..cca9fbb2 100644 --- a/docs/roadmap/generated-test-settings.md +++ b/docs/roadmap/generated-test-settings.md @@ -1,12 +1,12 @@ # FastMoq Generated Test Settings Design -This page captures the current `#162` design direction for generated-test authoring settings. It is design-level only. It does not imply shipped settings resolution, generator behavior changes, analyzer behavior changes, or external scaffolding support on the current branch. +This page captures the current `#162` design direction for generated-test authoring settings. It is design-level only. It does not imply shipped settings resolution, generator behavior changes, analyzer behavior changes, or external scaffolding support in the repo. ## Purpose And Non-Goals Purpose: -- define one shared authoring-time settings contract for later generated scenario scaffolds, full generated tests, analyzer-guided entry points, and any helper-builder output that explicitly opts into the same contract +- define one shared authoring-time settings contract for later generated scenario scaffolds, broader generated-test expansion, analyzer-guided entry points, and any helper-builder output that explicitly opts into the same contract - keep `#123`, `#124`, `#126`, `#136`, and conditionally `#137` aligned to one vocabulary for naming, platform targeting, regeneration, and extensibility - settle where those settings live, how they override one another, and which parts remain capability-gated by existing package-shape rules @@ -22,9 +22,9 @@ Non-goals: The current repo state that drives this design is: -- `#122` is complete for the narrow harness MVP: explicit partial `MockerTestBase` targets can emit constructor-signature metadata and harness bootstrap, but the repo still does not emit full generated tests, scenario scaffolds, or framework-helper builders. +- `#122` is complete for the narrow harness MVP, and `#136` now builds on that base for explicit partial `MockerTestBase` targets: the repo can emit constructor-signature metadata, harness bootstrap, generated scenario execution entry points, suite-level shared setup hooks, and the first narrow `#123` xUnit smoke-test slice inside those explicit harness partials, but it still does not emit standalone full generated test classes, settings-driven scaffold variants, or framework-helper builders. - `#127` is complete for package detection and target-shape rules: the analyzer layer already knows the supported generated target shapes for the referenced FastMoq package layout. -- `GeneratedHarnessSourceGenerator` currently hard-codes the target attribute metadata name, the `MockerTestBase` requirement, the generated metadata type name, the `ComponentConstructorParameterTypes` override, the auto-generated header, `#nullable enable`, and the `.FastMoq.GeneratedHarness.g.cs` hint-name suffix. +- `GeneratedHarnessSourceGenerator` currently hard-codes the target attribute metadata name, the `MockerTestBase` requirement, the generated metadata type name, the `ComponentConstructorParameterTypes` override, the auto-generated header, `#nullable enable`, the `.FastMoq.GeneratedHarness.g.cs` hint-name suffix, and the current xUnit-gated smoke-test naming and placeholder strategy. - `GeneratedTestTargetShapeRule` and `FastMoqAnalysisHelpers` currently own the package matrix, target-shape list, required package per shape, default base type name, and default namespaces for each supported generated test shape. - `Directory.Packages.props` currently carries `xunit` `2.9.3` and `xunit.runner.visualstudio` `3.0.2`, while the repo documentation remains framework-agnostic. That mixed baseline is one reason syntax targeting and runner or bootstrap targeting must stay separate settings. - `FastMoq.Generators.csproj` does not currently declare `CompilerVisibleProperty`, `CompilerVisibleItemMetadata`, or any equivalent custom bridge for generated-test authoring settings. @@ -33,7 +33,7 @@ The current repo state that drives this design is: - `#126` consumes the shared settings contract for regeneration-safe scenario-scaffolding hooks and customization boundaries, but it still owns the `ScenarioBuilder` contract surface rather than the settings carrier. - `#136` consumes the shared settings contract for concrete scenario and suite scaffolding implementation after `#126` settles the hook model. -- `#123` consumes the shared settings contract for naming, scaffold choice, framework syntax targeting, runner or bootstrap targeting, and regeneration behavior when widening from harness metadata into full generated tests. +- `#123` consumes the shared settings contract for naming, scaffold choice, framework syntax targeting, runner or bootstrap targeting, and regeneration behavior when widening from harness metadata into broader generated-test coverage. - `#124` consumes the shared settings contract so analyzer-guided generation suggestions can reflect the same naming and platform choices without inventing analyzer-local defaults. - `#137` is conditional: if helper-builder output needs shared naming, output, or regeneration rules, it should consume this contract later. This design does not assume that relationship unless a later implementation explicitly chooses it. @@ -108,7 +108,7 @@ Reserved later properties for external scaffolding flows are: JSON or `AdditionalFiles` manifests are explicitly deferred. They should only be introduced if the structured settings shape demonstrably outgrows practical MSBuild properties or simple semicolon-delimited list values. -Implementation note: although Roslyn analyzers and generators can observe `build_property.*` values through generated analyzer config, this repo does not currently expose any custom generated-test settings bridge in `FastMoq.Generators.csproj`. A future implementation slice must add the required compiler-visible-property or equivalent build plumbing before any custom settings can flow into analyzer or generator code. +Implementation note: although Roslyn analyzers and generators can observe `build_property.*` values through generated analyzer config, this repo does not currently expose any custom generated-test settings bridge in `FastMoq.Generators.csproj`. A future implementation slice must add the required compiler-visible-property or equivalent build plumbing before any custom settings can flow into analyzer or generator code. The current `#136` scaffold implementation and the first narrow `#123` smoke-test slice therefore consume the `#162` vocabulary as fixed generator-owned defaults rather than as user-configurable settings values. ## Extensibility Model @@ -242,7 +242,7 @@ Intended first-use classification by type: - `GeneratedTestAuthoringSettings`: top-level true user-setting aggregate; first carrier is MSBuild-backed analyzer-config values. - `GeneratedTestPackagePolicySettings`: capability-gated user settings; primary consumer is analyzer-guided or external scaffolding flows. - `GeneratedTestPlacementSettings`: mixed user setting plus deferred placeholders; `OutputProject` and `OutputFolder` are not first-class source-generator controls yet. -- `GeneratedTestNamingTemplateSettings`: true user settings; consumed by generated scenario scaffolds and full generated tests. +- `GeneratedTestNamingTemplateSettings`: true user settings; consumed by generated scenario scaffolds and broader generated-test expansion. - `GeneratedTestProviderSelectionSettings`: true user settings; constrained by referenced FastMoq provider packages. - `GeneratedTestSyntaxTargetSettings`: true user settings with explicit deferred values. - `GeneratedTestScaffoldPreferenceSettings`: mixed user settings and structured extensibility point. @@ -318,6 +318,8 @@ The following items are explicitly not user settings in `#162`. | Explicit partial-class requirement and `MockerTestBase` target eligibility | Not a setting | These are current MVP target-shape rules from `#122`. | | `FastMoqGeneratedHarnessMetadata` type name | Not a setting | Generated metadata naming is part of the current harness contract. | | `ComponentConstructorParameterTypes` hook name | Not a setting | This is the existing runtime and generated harness hook from `MockerTestBase`. | +| `ConfigureGeneratedMockerPolicy`, `ConfigureGeneratedMocks`, `AfterGeneratedComponentCreated`, `ArrangeGeneratedScenario`, `ActGeneratedScenario`, `ExpectedExceptionGeneratedScenario`, `AssertGeneratedScenario`, and `VerifyGeneratedScenario` hook names | Not a setting | Current companion partial hook names are generator-owned implementation details today. | +| `ExecuteGeneratedScenarioScaffold`, `ExecuteGeneratedScenarioScaffoldAsync`, `ExecuteGeneratedExpectedExceptionScenarioScaffold`, and `ExecuteGeneratedExpectedExceptionScenarioScaffoldAsync` member names | Not a setting | Current generated scenario entry-point names are generator-owned implementation details today. | | `.FastMoq.GeneratedHarness.g.cs` hint-name suffix | Not a setting | File hint naming is generator-owned implementation detail today. | | Current `// ` header and `#nullable enable` boilerplate | Not a setting | Generator-owned implementation detail today. | | `FastMoqGeneratedTestPackageLayout` enum values | Not a setting | Analyzer-owned capability boundary from `#127`. | @@ -341,8 +343,8 @@ This section exists to keep `#162` from silently reopening `#122` or `#127`. - `#162` ends once the shared settings contract, carrier model, precedence rules, support matrix, extensibility model, and invariants are documented and mirrored into the roadmap. - `#126` should then focus only on regeneration-safe scenario/scaffold hooks that consume this contract. -- `#136` should implement scenario and suite scaffolding against the `#126` hook contract plus the `#162` settings contract. -- `#123` should implement full generated tests against this contract without inventing local defaults for framework syntax, runner/bootstrap mode, naming, or regeneration behavior. +- the first `#136` slice now implements scenario and suite scaffolding against the `#126` hook contract plus the `#162` settings vocabulary, but future settings wiring still needs the compiler-visible-property bridge above before consumers can override those defaults. +- `#123` should implement broader generated-test coverage against this contract without inventing local defaults for framework syntax, runner/bootstrap mode, naming, or regeneration behavior. - `#124` should route analyzer suggestions into already-supported generation layers while using this contract for defaults and choices. - `#137` should only adopt this contract if helper-builder output actually needs the same naming, placement, regeneration, or framework-targeting choices. - provider-optimized generation evaluation in `#138` and narrower fake-generation evaluation in `#139` remain intentionally later. diff --git a/docs/roadmap/generator-roadmap.md b/docs/roadmap/generator-roadmap.md index b12ea94a..5b031705 100644 --- a/docs/roadmap/generator-roadmap.md +++ b/docs/roadmap/generator-roadmap.md @@ -2,13 +2,13 @@ This page captures the current v5 direction for FastMoq code generation. It is the detailed design companion to the main [roadmap summary](./README.md), not a shipped feature guide. -This page is intentionally design-level only. It is appropriate for roadmap and implementation planning ahead of code, but it does not imply current shipped support. +This page remains design-level heavy. It is appropriate for roadmap and implementation planning, but some sections also record the narrow generator slices currently supported in the repo. For the shared generated-test settings contract behind [#162](https://github.com/cwinland/FastMoq/issues/162), see [Generated test settings design](./generated-test-settings.md). For the scenario-scaffolding contract behind [#126](https://github.com/cwinland/FastMoq/issues/126), see [Generated scenario scaffolding contract](./generated-scenario-scaffolding-contract.md). For the helper-family narrowing contract behind [#134](https://github.com/cwinland/FastMoq/issues/134), see [Generated helper family matrix](./generated-helper-family-matrix.md). -FastMoq now contains a narrow first Roslyn source-generator slice for explicit `MockerTestBase` harness targets. The repo still does not emit full generated tests, scenario scaffolds, or broader framework-helper builders. +FastMoq now contains narrow Roslyn source-generator slices for explicit `MockerTestBase` harness targets, the first generated scenario and suite scaffolding layer inside those targets, and the first narrow generated-test slice inside those same harness partials. The repo still does not emit standalone generated test classes or broader framework-helper builders. ## Current Baseline @@ -16,10 +16,11 @@ Confirmed current state: - `FastMoq.Analyzers` ships analyzers and code fixes for migration, provider-first authoring, package guidance, and helper adoption. - `FastMoq.Generators` now contains the first `IIncrementalGenerator` implementation for explicit partial `MockerTestBase` targets. -- The current generated output is intentionally narrow: constructor-signature metadata and harness bootstrap for explicitly selected component paths. -- There is still no first-party compile-time generation of full tests, scenario scaffolding, or framework-helper builders. +- The current generated output is intentionally narrow: constructor-signature metadata and harness bootstrap for explicitly selected component paths, generated scenario execution entry points and suite-level shared setup hooks for explicit partial harness targets, and xUnit-gated smoke-test emission inside those same harness partials. +- The current generated-test slice is still bounded to public instance methods that are safe to execute with no inputs or with explicit compile-time default parameter values, with supported `void`, value-returning, `Task`, `Task`, `ValueTask`, and `ValueTask` return shapes. Unsupported methods stay compile-safe through skipped placeholders with explicit reasons. +- There is still no first-party compile-time generation of standalone full generated test classes or broader framework-helper builders, and the current full-test slice remains intentionally limited to explicit partial harness targets. -That means code generation in v5 is now an early implementation-facing surface rather than roadmap-only prose, but the broader generator line is still net-new beyond this first harness slice. +That means code generation in v5 is now an early implementation-facing surface rather than roadmap-only prose, but the broader generator line is still net-new beyond these first harness, scaffold, and smoke-test slices. ## Public Issue Crosswalk @@ -45,15 +46,15 @@ The current public backlog for this design is: Crosswalk summary: - `#121` is the runtime-prerequisite umbrella. -- `#132`, `#133`, and `#135` are the narrower v4-style quick wins that are now implemented on the current milestone branch. +- `#132`, `#133`, and `#135` are the narrower v4-style quick wins that are now implemented in the repo. - `#146` and `#147` carry the near-term analyzer follow-up for those landed helper surfaces. - `#120`, `#125`, `#126`, `#127`, and `#134` are the pre-v5 contract and helper-boundary prerequisite slices. - `#122` is the completed first implementation-facing MVP for compile-time graph metadata and harness bootstrap. -- `#162` is the current shared settings and test-platform contract gate before wider generated scenario scaffolds, full generated tests, and analyzer entry points. -- `#126` is the immediate next design slice after `#162` and defines the stable scenario-scaffolding contract layer. -- `#134` is the next narrowing and re-triage pass after `#126`, and it classifies which helper families are green-light, bounded, deferred, or split to later follow-on work for `#136`. -- `#136` is the next implementation slice once the scenario contract and helper boundaries are explicit. -- `#123` and `#124` remain later phased implementation and authoring-flow outcomes after the contract and scaffold layers are stable. +- `#162` is the current shared settings and test-platform contract gate before wider generated scenario scaffolds, broader generated-test expansion, and analyzer entry points. +- `#126` defines the stable scenario-scaffolding contract layer that the current generated scaffold slice now targets. +- `#134` documents the helper-family bounds that the current scaffold slice stays within and that later helper-heavy scaffold expansion must continue to honor. +- `#136` is now the first implementation slice for generated scenario and suite scaffolding: generated scenario execution helpers, companion partial hooks, and suite-level shared setup composition inside explicit partial harness targets. +- `#123` now has its first narrow implementation slice inside explicit harness partials, while `#124` remains later analyzer-guided authoring flow after the current generated-test boundary is stable. - `#137` remains later and conditional rather than part of the immediate next-step chain. - `#138` and `#139` are intentionally late evaluation tracks after the main provider-first generator story is already working. @@ -124,14 +125,27 @@ Why it goes first: ### 2. Scenario and suite scaffolding -This workstream should build on generated graph metadata rather than replace it. +This workstream now has its first implementation slice and still has explicit bounds. -Primary outputs: +Current implemented outputs: + +- generated scenario execution entry points and companion partial hooks inside explicit partial `MockerTestBase` targets +- generated suite-level shared setup regions through `ConfigureGeneratedMockerPolicy`, `ConfigureGeneratedMocks`, and `AfterGeneratedComponentCreated` +- generated sync, async, and continued-assertion expected-exception scaffold entry points built on the existing `ScenarioBuilder` pipeline + +Current supported shape: -- generated scenario shell types or partials -- generated per-suite setup regions -- generated default verify helpers or scenario hooks -- generated migration-starting points for repeated patterns +- generated members stay inside explicit partial harness types rather than standalone generated suite classes +- suite scaffolding for `#136` means shared setup composition for the generated harness, not separate project-level test containers +- verification stays provider-first through existing `ScenarioBuilder` and `Mocks` surfaces +- the current slice uses the settled `#162` hook vocabulary and built-in defaults rather than consumer-configurable settings values + +Explicitly deferred from this slice: + +- standalone generated suite or scenario types outside the harness partial +- settings-driven framework syntax, runner or bootstrap selection, naming templates, or helper-family-specific emission +- generated wrappers that return the thrown exception object through `ExecuteThrows()` or `ExecuteThrowsAsync()`; use a hand-written `Scenario` flow when the exception object itself is the primary assertion target +- full generated test classes and helper-builder expansion Expected value: @@ -141,7 +155,28 @@ Expected value: ### 3. Full-test generation from existing services and classes -This workstream should generate complete starting tests for real existing code, not just partial harness fragments. +This workstream now has its first implementation-facing slice, but the broader work still aims at complete starting tests for real existing code rather than partial harness fragments alone. + +Current implemented outputs: + +- xUnit smoke tests emitted inside explicit partial `MockerTestBase` harness targets when `Xunit.FactAttribute` is available in the compilation +- one component-creation smoke test plus one generated smoke test per eligible public instance component method +- generated calls for methods that take no parameters or only explicit compile-time defaulted parameters +- skipped placeholders with explicit reasons for unsupported methods and shapes + +Current supported shape: + +- generated tests stay inside the existing explicit harness partial rather than emitting standalone generated test classes +- method execution is limited to public instance methods with either no parameters or explicit compile-time default values that can be emitted safely +- supported return shapes are `void`, value-returning sync methods, `Task`, `Task`, `ValueTask`, and `ValueTask` +- generated smoke tests are currently xUnit-specific and only emit when the xUnit surface is actually available in the compilation + +Explicitly deferred from this slice: + +- standalone generated test classes for services, controllers, handlers, or other broader target shapes +- methods that require non-optional parameters, generic methods, `ref` or `out` parameters, or optional defaults that cannot be emitted safely from metadata +- settings-driven framework syntax, naming templates, runner selection, assertion-style selection, or helper-family-specific full-test emission +- analyzer entry points and missing-package suggestion flows in `#124` Primary outputs: @@ -238,7 +273,7 @@ Without those runtime targets, generators would be forced to emit provider-nativ Some runtime preparation work was narrow enough to land before v5 without forcing a wider public-contract redesign. -Completed on the current milestone branch: +Completed in the repo: - expanded provider-first setup helpers for common simple arrangements - expanded provider-first verification helpers where a shared abstraction is still clear and stable @@ -250,7 +285,7 @@ These landed early because they improve normal authoring even before source gene Some work is more foundational and should be treated as explicit prerequisites for the generator implementation itself. -Completed on the current milestone branch: +Completed in the repo: - stable graph metadata hooks and reusable constructor-selection primitives for generator output - clear package-detection and target-test-shape rules so generated tests do not assume helper packages that are not referenced @@ -391,7 +426,7 @@ That parity matrix is part of the definition artifact for this slice. It does no The first implementation step after [#125](https://github.com/cwinland/FastMoq/issues/125) is now in place. -Done in the current branch: +Done in the repo: - an internal `InstanceConstructionGraph` model now projects the selected root constructor plan plus ordered dependency nodes and edges from `Mocker.CreateConstructionPlan(...)` - `MockerTestBase` now exposes the first harness-side consumer through `GetComponentConstructionGraph()` @@ -404,15 +439,16 @@ Done in the current branch: What now moves past [#122](https://github.com/cwinland/FastMoq/issues/122): - broader generated-test settings and framework or runner targeting now live in [#162](https://github.com/cwinland/FastMoq/issues/162) rather than the graph and harness MVP -- generated scenario or suite scaffolding still belongs in [#136](https://github.com/cwinland/FastMoq/issues/136) -- full generated tests and analyzer entry points still belong in [#123](https://github.com/cwinland/FastMoq/issues/123) and [#124](https://github.com/cwinland/FastMoq/issues/124) +- the first generated scenario or suite scaffolding slice now lives in [#136](https://github.com/cwinland/FastMoq/issues/136) through generated scenario execution helpers and suite-level shared setup hooks inside explicit partial harness targets +- broader generated-test expansion and analyzer entry points still belong in [#123](https://github.com/cwinland/FastMoq/issues/123) and [#124](https://github.com/cwinland/FastMoq/issues/124) Preferred post-`#122` decision: - keep the public planning API unchanged -- use [#162](https://github.com/cwinland/FastMoq/issues/162) to settle generated-test settings and framework or runner targeting before widening into scaffolds or full tests +- use [#162](https://github.com/cwinland/FastMoq/issues/162) to wire consumer-configurable generated-test settings before widening beyond the current fixed scaffold defaults +- treat the current [#136](https://github.com/cwinland/FastMoq/issues/136) scaffold shape as the stable narrow baseline: explicit partial harness targets, companion partial hooks, and suite-level shared setup composition - only enrich the internal graph model further if a later generation layer proves that more dependency-order metadata is actually required for compilation or parity -- move next into the scenario-contract slice in [#126](https://github.com/cwinland/FastMoq/issues/126), then the helper-family narrowing pass in [#134](https://github.com/cwinland/FastMoq/issues/134), then the next implementation-facing generator work in [#136](https://github.com/cwinland/FastMoq/issues/136), with [#123](https://github.com/cwinland/FastMoq/issues/123) and [#124](https://github.com/cwinland/FastMoq/issues/124) following only after the contract and scaffold layers are stable +- move next into [#123](https://github.com/cwinland/FastMoq/issues/123) and [#124](https://github.com/cwinland/FastMoq/issues/124) only after the current scaffold boundary is stable enough to consume without re-deriving local defaults ## Suggested v5 Delivery Phases @@ -438,6 +474,8 @@ Ship: ### Phase 2: scenario and scaffold generation +The first step of this phase is now in place for explicit partial harness targets. + Ship: - generated suite or scenario scaffolds @@ -507,10 +545,10 @@ The current doc plan now maps to these issue slices: 5. Tighten the existing logging, HTTP, and typed DI helper surfaces that are small enough to land as v4 quick wins. 6. Define graph metadata hooks and constructor-selection contracts for generator-targeted output. 7. Define `ScenarioBuilder` scaffolding hooks and regeneration-safe extension points for generated output. -8. Define package-detection and target-test-shape rules for package-aware generation. This is now implemented on the current milestone branch through the shared analyzer package matrix. +8. Define package-detection and target-test-shape rules for package-aware generation. This is now implemented in the repo through the shared analyzer package matrix. 9. Normalize the broader blocking helper surfaces for logging, HTTP, Azure, Azure Functions, and typed DI-heavy setup. 10. Implement compile-time test graph and harness generation MVP. -11. Implement generated scenario and suite scaffolding after the graph and harness MVP. +11. Implement generated scenario and suite scaffolding after the graph and harness MVP. This is now in place for explicit partial harness targets through generated scenario execution helpers and suite-level shared setup hooks. 12. Implement generator-backed framework-helper builders for repeated test patterns. 13. Add full-test generation for supported existing services and other supported classes. 14. Add analyzer guidance for untested code plus package-aware suggestions before test generation.