From c3b733cb5a6869c635adf5ec8ba1deb85e9f3f3c Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 11:31:38 +0200 Subject: [PATCH 1/7] Reference the Cratis packages the specs need, not the meta-package The Cratis meta-package contributes GlobalUsings.cs through Cratis.props, so every consumer of the renderer silently gained 28 global usings and the ambiguities they bring - 160 compilation errors in the CLI alone. Naming Arc, Arc.Chronicle, Arc.Swagger, Chronicle and MongoDB.Driver directly restores the same compilation for the specs without any build assets, and drops 16 packages from what a consumer restores. --- Directory.Packages.props | 7 +++++-- Source/Rendering.Cratis/Rendering.Cratis.csproj | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9bb3e62..2e69cf4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,11 @@ - - + + + + + diff --git a/Source/Rendering.Cratis/Rendering.Cratis.csproj b/Source/Rendering.Cratis/Rendering.Cratis.csproj index ab4f75b..434a54e 100644 --- a/Source/Rendering.Cratis/Rendering.Cratis.csproj +++ b/Source/Rendering.Cratis/Rendering.Cratis.csproj @@ -17,6 +17,13 @@ Specs compile the rendered output in memory against the real Cratis assemblies — asserting on generated strings is what let a whole class of non-compiling output ship unnoticed. These are private assets: the renderer itself does not depend on Cratis at runtime, only its specs do. + + Named individually rather than through the Cratis meta-package: that package contributes + GlobalUsings.cs through its Cratis.props, so every consumer of this one silently gains 28 global + usings and the type collisions that come with them. These carry no build assets. Arc.Chronicle and + Arc.Swagger are here only so the spec compilation can model the ambient namespaces a real Cratis + application has, not because rendered code names anything in them; MongoDB.Driver is, because a + rendered read model exposes its collection query as IMongoCollection<T>. --> - - + + + + + From 2cb5d56b327b05f7f2dbf7948156a509ffb98fff Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 11:49:04 +0200 Subject: [PATCH 2/7] Take the spec-only Cratis references in Debug only The renderer needs Arc, Chronicle and the Mongo driver to compile its rendered output inside a spec, and needs none of them to render. Gating the references on Debug - the same condition Directory.Build.props already uses for the test packages, and Directory.Build.targets for the spec files themselves - keeps the specs and takes the runtime out of the package. It also stops the type-discovery generator, which was reaching the compilation transitively rather than through the two direct references ExcludeAssets was applied to. The Release assembly goes from 698 Cratis type references and nine Arc and Chronicle assemblies to Screenplay and Stage.Contracts alone. --- Source/Rendering.Cratis/Rendering.Cratis.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Source/Rendering.Cratis/Rendering.Cratis.csproj b/Source/Rendering.Cratis/Rendering.Cratis.csproj index 434a54e..a016d09 100644 --- a/Source/Rendering.Cratis/Rendering.Cratis.csproj +++ b/Source/Rendering.Cratis/Rendering.Cratis.csproj @@ -25,7 +25,7 @@ application has, not because rendered code names anything in them; MongoDB.Driver is, because a rendered read model exposes its collection query as IMongoCollection<T>. --> - + + + diff --git a/Source/Rendering.Cratis.Scaffolding/Rendering.Cratis.Scaffolding.csproj b/Source/Rendering.Cratis.Scaffolding/Rendering.Cratis.Scaffolding.csproj new file mode 100644 index 0000000..3e7c8dd --- /dev/null +++ b/Source/Rendering.Cratis.Scaffolding/Rendering.Cratis.Scaffolding.csproj @@ -0,0 +1,20 @@ + + + Cratis.Stage.Rendering.Cratis.Scaffolding + Cratis.Stage.Rendering.Cratis.Scaffolding + true + true + Scaffolds the Cratis project a rendered Screenplay application is placed into, from the Cratis templates. + + false + + + + + + + + + + + diff --git a/Source/Rendering.Cratis/Scaffolding/SampleSlice.cs b/Source/Rendering.Cratis.Scaffolding/SampleSlice.cs similarity index 100% rename from Source/Rendering.Cratis/Scaffolding/SampleSlice.cs rename to Source/Rendering.Cratis.Scaffolding/SampleSlice.cs diff --git a/Source/Rendering.Cratis/Scaffolding/ScaffoldingFailed.cs b/Source/Rendering.Cratis.Scaffolding/ScaffoldingFailed.cs similarity index 100% rename from Source/Rendering.Cratis/Scaffolding/ScaffoldingFailed.cs rename to Source/Rendering.Cratis.Scaffolding/ScaffoldingFailed.cs diff --git a/Source/Rendering.Cratis/Scaffolding/TemplateEngineProjectScaffolder.cs b/Source/Rendering.Cratis.Scaffolding/TemplateEngineProjectScaffolder.cs similarity index 100% rename from Source/Rendering.Cratis/Scaffolding/TemplateEngineProjectScaffolder.cs rename to Source/Rendering.Cratis.Scaffolding/TemplateEngineProjectScaffolder.cs diff --git a/Source/Rendering.Cratis/Scaffolding/TemplateNotFound.cs b/Source/Rendering.Cratis.Scaffolding/TemplateNotFound.cs similarity index 100% rename from Source/Rendering.Cratis/Scaffolding/TemplateNotFound.cs rename to Source/Rendering.Cratis.Scaffolding/TemplateNotFound.cs diff --git a/Source/Rendering.Cratis/Scaffolding/TemplatePackageNotFound.cs b/Source/Rendering.Cratis.Scaffolding/TemplatePackageNotFound.cs similarity index 100% rename from Source/Rendering.Cratis/Scaffolding/TemplatePackageNotFound.cs rename to Source/Rendering.Cratis.Scaffolding/TemplatePackageNotFound.cs diff --git a/Source/Rendering.Cratis/for_TemplateEngineProjectScaffolder/when_a_project_already_exists.cs b/Source/Rendering.Cratis.Scaffolding/for_TemplateEngineProjectScaffolder/when_a_project_already_exists.cs similarity index 100% rename from Source/Rendering.Cratis/for_TemplateEngineProjectScaffolder/when_a_project_already_exists.cs rename to Source/Rendering.Cratis.Scaffolding/for_TemplateEngineProjectScaffolder/when_a_project_already_exists.cs diff --git a/Source/Rendering.Cratis/for_TemplateEngineProjectScaffolder/when_scaffolding_a_new_project.cs b/Source/Rendering.Cratis.Scaffolding/for_TemplateEngineProjectScaffolder/when_scaffolding_a_new_project.cs similarity index 100% rename from Source/Rendering.Cratis/for_TemplateEngineProjectScaffolder/when_scaffolding_a_new_project.cs rename to Source/Rendering.Cratis.Scaffolding/for_TemplateEngineProjectScaffolder/when_scaffolding_a_new_project.cs diff --git a/Source/Rendering.Cratis/CompatibilitySuppressions.xml b/Source/Rendering.Cratis/CompatibilitySuppressions.xml new file mode 100644 index 0000000..5cdedfb --- /dev/null +++ b/Source/Rendering.Cratis/CompatibilitySuppressions.xml @@ -0,0 +1,46 @@ + + + + + CP0001 + T:Cratis.Arc.Generated.GeneratedMarker + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + true + + + CP0001 + T:Cratis.Stage.Rendering.Cratis.Scaffolding.SampleSlice + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + true + + + CP0001 + T:Cratis.Stage.Rendering.Cratis.Scaffolding.ScaffoldingFailed + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + true + + + CP0001 + T:Cratis.Stage.Rendering.Cratis.Scaffolding.TemplateEngineProjectScaffolder + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + true + + + CP0001 + T:Cratis.Stage.Rendering.Cratis.Scaffolding.TemplateNotFound + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + true + + + CP0001 + T:Cratis.Stage.Rendering.Cratis.Scaffolding.TemplatePackageNotFound + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + lib/net10.0/Cratis.Stage.Rendering.Cratis.dll + true + + \ No newline at end of file diff --git a/Source/Rendering.Cratis/CratisRenderer.cs b/Source/Rendering.Cratis/CratisRenderer.cs index bff3d04..4feb8a8 100644 --- a/Source/Rendering.Cratis/CratisRenderer.cs +++ b/Source/Rendering.Cratis/CratisRenderer.cs @@ -23,9 +23,14 @@ namespace Cratis.Stage.Rendering.Cratis; public class CratisRenderer(IProjectScaffolder scaffolder, IReadOnlyDictionary sliceRenderers, ICodeOutput codeOutput) : IRenderer { /// - /// Creates a wired with the real scaffolder, slice renderers, and local file - /// system output. + /// Creates a wired with the slice renderers and local file system output, + /// rendering into the target directory without scaffolding a project around it. /// + /// + /// Pass a to the constructor to scaffold as well — the template-engine + /// one lives in Cratis.Stage.Rendering.Cratis.Scaffolding, kept out of this package because the + /// engine it needs cannot be hosted beside MSBuild. + /// /// The . public static CratisRenderer CreateDefault() { @@ -38,7 +43,7 @@ public static CratisRenderer CreateDefault() [SliceType.Translate] = reactorRenderer, }; - return new CratisRenderer(new TemplateEngineProjectScaffolder(), sliceRenderers, new LocalFileSystemOutput()); + return new CratisRenderer(new TargetDirectoryScaffolder(), sliceRenderers, new LocalFileSystemOutput()); } /// diff --git a/Source/Rendering.Cratis/Rendering.Cratis.csproj b/Source/Rendering.Cratis/Rendering.Cratis.csproj index a016d09..89f0d10 100644 --- a/Source/Rendering.Cratis/Rendering.Cratis.csproj +++ b/Source/Rendering.Cratis/Rendering.Cratis.csproj @@ -8,10 +8,6 @@ - - - - + + diff --git a/Source/Rendering.Cratis/CratisRenderer.cs b/Source/Rendering.Cratis/CratisRenderer.cs index bff3d04..d01d952 100644 --- a/Source/Rendering.Cratis/CratisRenderer.cs +++ b/Source/Rendering.Cratis/CratisRenderer.cs @@ -8,6 +8,7 @@ using Cratis.Stage.Rendering.Cratis.Naming; using Cratis.Stage.Rendering.Cratis.Renderers; using Cratis.Stage.Rendering.Cratis.Scaffolding; +using Cratis.Stage.Rendering.Cratis.Specifications; namespace Cratis.Stage.Rendering.Cratis; @@ -196,6 +197,7 @@ async Task RenderSlice(LocatedSlice slice, ApplicationSet applicationSet, string { await output.WriteLineAsync($"Rendering slice '{slicePath}'..."); await WriteFile(renderer.Render(slice, applicationSet, rootNamespace), targetDirectory, output, error); + await RenderSpecifications(slice, applicationSet, rootNamespace, targetDirectory, output, error); } catch (Exception exception) { @@ -203,6 +205,34 @@ async Task RenderSlice(LocatedSlice slice, ApplicationSet applicationSet, string } } + /// + /// Renders the slice's specifications, one file each. A specification that cannot be rendered faithfully is + /// reported rather than emitted — a spec asserting something the document did not state is worse than none. + /// + /// The located slice whose specifications to render. + /// The to resolve against. + /// The root namespace of the target application. + /// The directory to render into. + /// The progress is reported to. + /// The rendering problems are reported to. + /// A representing the asynchronous operation. + async Task RenderSpecifications( + LocatedSlice slice, ApplicationSet applicationSet, string rootNamespace, DirectoryInfo targetDirectory, TextWriter output, TextWriter error) + { + var command = slice.Slice.Commands.FirstOrDefault(); + + foreach (var specification in slice.Slice.Specifications) + { + if (SpecificationRenderer.Unrenderable(specification, command) is { } reason) + { + await error.WriteLineAsync($"Specification '{specification.Name}' is not rendered — {reason}."); + continue; + } + + await WriteFile(SpecificationRenderer.Render(specification, command!, slice, applicationSet, rootNamespace), targetDirectory, output, error); + } + } + async Task WriteFile(RenderedFile file, DirectoryInfo targetDirectory, TextWriter output, TextWriter error) { try diff --git a/Source/Rendering.Cratis/Naming/Identifiers.cs b/Source/Rendering.Cratis/Naming/Identifiers.cs index 380dcd5..200fbf0 100644 --- a/Source/Rendering.Cratis/Naming/Identifiers.cs +++ b/Source/Rendering.Cratis/Naming/Identifiers.cs @@ -56,6 +56,14 @@ public static string ToCamelCase(string name) /// The lowercase, space-separated words. public static string ToWords(string name) => string.Join(' ', SplitWords(name).SelectMany(SplitOnCaseBoundary)).ToLowerInvariant(); + /// + /// Converts a name into snake_case — used for the spec folder and class names the repository conventions + /// use, where RegisteringADraftInvoice reads as registering_a_draft_invoice. + /// + /// The name to convert. + /// The snake_case name. + public static string ToSnakeCase(string name) => ToWords(name).Replace(' ', '_'); + /// /// Escapes an identifier with @ when it is a reserved C# keyword. /// diff --git a/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs b/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs index 260b3d3..e35f2b3 100644 --- a/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs +++ b/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs @@ -80,10 +80,9 @@ public static void Report(CSharpCodeBuilder builder, SliceSyntax slice, Rendered slice.Captures.Count(), "capture", "no ingestion of the captured source is rendered."); - yield return ( - slice.Specifications.Count(), - "specification", - "no specs are rendered for the generated application."); + + // Specifications are rendered separately, one file each, and each one that cannot be says so on its own + // — so counting them here would report the same thing twice and count the rendered ones as dropped. } // Both collections are trailing optionals on SliceSyntax and are null on a slice that declares neither. diff --git a/Source/Rendering.Cratis/Rendering.Cratis.csproj b/Source/Rendering.Cratis/Rendering.Cratis.csproj index ab4f75b..3ba1499 100644 --- a/Source/Rendering.Cratis/Rendering.Cratis.csproj +++ b/Source/Rendering.Cratis/Rendering.Cratis.csproj @@ -25,6 +25,8 @@ has no reason to do. --> + + diff --git a/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs b/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs new file mode 100644 index 0000000..aa27261 --- /dev/null +++ b/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs @@ -0,0 +1,95 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Stage.Rendering.Cratis.Naming; + +namespace Cratis.Stage.Rendering.Cratis.Specifications; + +/// +/// Renders what a specification asserts about the events its command appended. +/// +public static class SpecificationAssertions +{ + /// + /// Renders the event source the appended events are asserted against — the value the specification states + /// for the command's own identifier. + /// + /// The command the specification exercises. + /// The declared command. + /// The to resolve the identifier type against. + /// Collects anything that could not be rendered faithfully. + /// The rendered event source id. + /// + /// A command appends to the event source its identifier names, so the value the specification states for that + /// property is the one the assertion filters on. A specification that states no value for it has not said + /// which event source it means; the assertion is rendered against the empty one, which fails rather than + /// passing on the wrong stream. + /// + public static string Of( + SpecificationCommandSyntax when, CommandSyntax command, ApplicationSet applicationSet, ICollection diagnostics) + { + var identifier = command.Properties.FirstOrDefault(property => property.IsIdentifier); + if (identifier is null) + { + diagnostics.Add($"Command '{command.Name}' declares no identifier, so the appended events are asserted against no event source."); + return "EventSourceId.Unspecified"; + } + + var stated = when.Values.FirstOrDefault(value => string.Equals(value.Property, identifier.Name, StringComparison.OrdinalIgnoreCase)); + if (stated?.Source is not LiteralExpressionSyntax { Value: string text }) + { + diagnostics.Add( + $"The specification states no value for '{identifier.Name}', which is what says which event source " + + "the appended events belong to."); + return "EventSourceId.Unspecified"; + } + + return $"new EventSourceId({CodeGeneration.CSharpCodeBuilder.StringLiteral(text)})"; + } + + /// + /// Renders the predicate narrowing an appended-event assertion to the values the specification states, or an + /// empty string when it states none beyond the event type. + /// + /// The expected event. + /// The to resolve the event's property types against. + /// Collects anything that could not be rendered faithfully. + /// The rendered predicate, prefixed with a comma, or an empty string. + public static string Predicate(SpecificationEventSyntax @event, ApplicationSet applicationSet, ICollection diagnostics) + { + var declared = applicationSet.Events.GetValueOrDefault(@event.EventType); + if (declared is null) + { + diagnostics.Add($"Event '{@event.EventType}' is not declared in this application, so only its type is asserted."); + return string.Empty; + } + + var comparisons = @event.Values + .Select(value => (Value: value, Property: declared.Properties.FirstOrDefault( + property => string.Equals(property.Name, value.Property, StringComparison.OrdinalIgnoreCase)))) + .Where(pair => pair.Property is not null) + .Select(pair => Comparison(pair.Value, pair.Property!, @event.EventType, applicationSet, diagnostics)) + .Where(comparison => comparison is not null) + .ToArray(); + + return comparisons.Length == 0 ? string.Empty : $", @event => {string.Join(" && ", comparisons)}"; + } + + static string? Comparison( + PropertyMappingSyntax value, + PropertySyntax property, + string eventType, + ApplicationSet applicationSet, + ICollection diagnostics) + { + if (value.Source is not LiteralExpressionSyntax literal) + { + return null; + } + + var rendered = SpecificationValues.Literal(literal.Value, property.Type, property.Name, eventType, applicationSet, diagnostics); + return rendered == "default!" ? null : $"@event.{Identifiers.ToPascalCase(property.Name)} == {rendered}"; + } +} diff --git a/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs new file mode 100644 index 0000000..dfdb71f --- /dev/null +++ b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs @@ -0,0 +1,210 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Stage.Rendering.Cratis.CodeGeneration; +using Cratis.Stage.Rendering.Cratis.Naming; + +namespace Cratis.Stage.Rendering.Cratis.Specifications; + +/// +/// Renders a Screenplay specification as a Cratis spec — a CommandScenario<T> exercising the +/// slice's own command and asserting on what it appended. +/// +/// +/// +/// One file per specification, in the folder layout the repository conventions use, wrapped in #if DEBUG +/// so spec code ships only in Debug. A Screenplay specification carries a single name rather than a +/// behavior/outcome pair, so it renders as a single when_ file rather than being split into a hierarchy +/// the document never stated. +/// +/// +/// A specification declaring given is not rendered at all — see . Rendering it +/// without its prior state would produce a spec that passes or fails for reasons the document did not state, +/// which is worse than not having it. +/// +/// +public static class SpecificationRenderer +{ + /// + /// Says why a specification cannot be rendered faithfully, or when it can. + /// + /// The specification to consider. + /// The command the slice declares, if any. + /// The reason, or . + /// + /// The given case is the interesting one, and it is not a gap in the target: CommandScenario + /// gained Given.ForEventSource(id).Events(…). It is a gap in the document — a given + /// event names no event source, and which one it belongs to is not recoverable. It is frequently not the + /// command's own: a specification asserting that a duplicate invoice number is rejected seeds an + /// InvoiceRegistered for a different invoice than the one the command registers, and seeding it + /// against the command's id would make the spec assert something else entirely. + /// + public static string? Unrenderable(SpecificationSyntax specification, CommandSyntax? command) + { + if (command is null || specification.When is null) + { + return "it exercises no command, and only a command scenario is rendered"; + } + + if (!string.Equals(specification.When.CommandType, command.Name, StringComparison.OrdinalIgnoreCase)) + { + return $"it exercises '{specification.When.CommandType}', which this slice does not declare"; + } + + if (specification.Given.Any()) + { + return "it establishes prior state with 'given', and the document does not say which event source " + + "those events belong to — seeding them against the command's own would assert something the " + + "document did not state"; + } + + if (specification.GivenReadModels?.Any() == true || specification.ThenReadModels?.Any() == true) + { + return "it states read model state, which has no assertion in the scenario family"; + } + + if (!specification.ThenEvents.Any() && !specification.ThenErrors.Any()) + { + return "it asserts nothing"; + } + + return null; + } + + /// + /// Renders a specification. + /// + /// The specification to render. + /// The command the slice declares. + /// The located slice the specification belongs to. + /// The to resolve types against. + /// The root namespace of the target application. + /// The . + public static RenderedFile Render( + SpecificationSyntax specification, + CommandSyntax command, + LocatedSlice slice, + ApplicationSet applicationSet, + string rootNamespace) + { + var diagnostics = new List(); + var name = Behavior(specification.Name); + var commandType = Identifiers.ToPascalCase(command.Name); + var builder = new CSharpCodeBuilder() + .Namespace($"{SliceNaming.Namespace(rootNamespace, slice.FullPath)}.{name}") + .Using("Cratis.Arc.Testing.Commands") + .Using("Cratis.Specifications") + .Using("Xunit"); + + builder.BlankLine().OpenBlock($"public class {name} : Specification") + .Line($"readonly CommandScenario<{commandType}> _scenario = new();") + .Line("CommandResult _result = null!;") + .BlankLine() + .Line($"async Task Because() => _result = await _scenario.Execute(new {commandType}({Arguments(specification.When!, command, specification, applicationSet, diagnostics)}));") + .BlankLine(); + + if (specification.ThenErrors.Any()) + { + RenderRejection(builder, specification, diagnostics); + } + else + { + RenderAppends(builder, specification, command, commandType, applicationSet, diagnostics); + } + + builder.EndBlock(); + + var path = new List(SliceNaming.FolderPath(slice.FullPath)) { $"{name}.cs" }; + return new RenderedFile(Path.Combine([.. path]), Conditional(builder.ToString())) { Diagnostics = diagnostics }; + } + + /// + /// Renders the assertions for a rejected command. Both are emitted deliberately: on its own + /// ShouldNotBeSuccessful cannot tell a validation rejection from an unhandled exception. A message the + /// document states is not asserted on — the conventions hold that message strings are presentation text. + /// + /// The to emit to. + /// The specification being rendered. + /// Collects anything that could not be rendered faithfully. + static void RenderRejection(CSharpCodeBuilder builder, SpecificationSyntax specification, List diagnostics) + { + var named = specification.ThenErrors.Where(error => !string.IsNullOrWhiteSpace(error.Name)).ToArray(); + if (named.Length > 0) + { + diagnostics.Add( + $"Specification '{specification.Name}' names {named.Length} expected rejection(s), which is not " + + "asserted on — the conventions hold that a rejection's text is presentation, and the specification's " + + "own name is where the reason belongs."); + } + + builder.Line("[Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();") + .Line("[Fact] void should_have_validation_errors() => _result.ShouldHaveValidationErrors();"); + } + + static void RenderAppends( + CSharpCodeBuilder builder, + SpecificationSyntax specification, + CommandSyntax command, + string commandType, + ApplicationSet applicationSet, + List diagnostics) + { + builder.Using("Cratis.Arc.Chronicle.Testing.Commands").Line("[Fact] void should_succeed() => _result.ShouldBeSuccessful();"); + + var identifier = SpecificationAssertions.Of(specification.When!, command, applicationSet, diagnostics); + + foreach (var @event in specification.ThenEvents) + { + var eventType = Identifiers.ToPascalCase(@event.EventType); + var predicate = SpecificationAssertions.Predicate(@event, applicationSet, diagnostics); + builder.Line( + $"[Fact] async Task should_have_appended_{Identifiers.ToSnakeCase(@event.EventType)}() => " + + $"await _scenario.ShouldHaveAppendedEvent<{commandType}, {eventType}>({identifier}{predicate});"); + } + } + + /// + /// Renders the command's constructor arguments from the values the specification states. A property the + /// specification says nothing about is constructed as a missing value and reported — the rendered spec then + /// exercises a command the document only partly described, which is worth knowing when it fails. + /// + /// The command the specification exercises. + /// The declared command. + /// The specification being rendered, for diagnostics. + /// The to resolve types against. + /// Collects anything that could not be rendered faithfully. + /// The rendered argument list. + static string Arguments( + SpecificationCommandSyntax when, + CommandSyntax command, + SpecificationSyntax specification, + ApplicationSet applicationSet, + List diagnostics) + { + var unstated = command.Properties + .Where(property => !when.Values.Any(value => string.Equals(value.Property, property.Name, StringComparison.OrdinalIgnoreCase))) + .Select(property => property.Name) + .ToArray(); + + if (unstated.Length > 0) + { + diagnostics.Add( + $"Specification '{specification.Name}' states no value for {string.Join(", ", unstated.Select(name => $"'{name}'"))} " + + $"of command '{command.Name}' — the rendered spec constructs them as missing values."); + } + + return string.Join(", ", command.Properties.Select(property => SpecificationValues.For(property, when.Values, command.Name, applicationSet, diagnostics))); + } + + /// + /// Turns the specification's name into the behavior the folder and class read as — RegisteringADraftInvoice + /// becomes when_registering_a_draft_invoice. + /// + /// The declared specification name. + /// The rendered behavior name. + static string Behavior(string name) => $"when_{Identifiers.ToSnakeCase(name)}"; + + static string Conditional(string content) => $"#if DEBUG{Environment.NewLine}{content}{Environment.NewLine}#endif{Environment.NewLine}"; +} diff --git a/Source/Rendering.Cratis/Specifications/SpecificationValues.cs b/Source/Rendering.Cratis/Specifications/SpecificationValues.cs new file mode 100644 index 0000000..cee8846 --- /dev/null +++ b/Source/Rendering.Cratis/Specifications/SpecificationValues.cs @@ -0,0 +1,139 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using Cratis.Screenplay.Syntax; +using Cratis.Stage.Rendering.Cratis.CodeGeneration; +using Cratis.Stage.Rendering.Cratis.Naming; +using Cratis.Stage.Rendering.Cratis.Types; + +namespace Cratis.Stage.Rendering.Cratis.Specifications; + +/// +/// Renders the values a specification states, as the C# the declared type can take. +/// +/// +/// A Screenplay document writes every value as a literal — a uuid is written as a string, an enum member as a +/// string — because the language's types say what they mean. The generated C# types do not accept that +/// literal directly, so each one is rendered through the conversion its declared type actually has. A literal +/// no conversion reaches is reported rather than forced. +/// +public static class SpecificationValues +{ + /// + /// Renders the constructor argument for one declared property, from the values the specification states. + /// + /// The declared property to render an argument for. + /// The values the specification states. + /// What declares the property, for diagnostics. + /// The to resolve the type against. + /// Collects anything that could not be rendered faithfully. + /// The rendered argument. + public static string For( + PropertySyntax property, + IEnumerable values, + string owner, + ApplicationSet applicationSet, + ICollection diagnostics) + { + var stated = values.FirstOrDefault(value => string.Equals(value.Property, property.Name, StringComparison.OrdinalIgnoreCase)); + if (stated is null) + { + return "default!"; + } + + if (stated.Source is not LiteralExpressionSyntax literal) + { + diagnostics.Add( + $"'{property.Name}' of '{owner}' is stated as a {stated.Source.GetType().Name}, which a specification " + + "value cannot be — only a literal is rendered."); + return "default!"; + } + + return Literal(literal.Value, property.Type, property.Name, owner, applicationSet, diagnostics); + } + + /// + /// Renders a literal as the declared type takes it. + /// + /// The literal value the document states. + /// The declared type of what it fills. + /// The property being filled, for diagnostics. + /// What declares the property, for diagnostics. + /// The to resolve the underlying type against. + /// Collects anything that could not be rendered faithfully. + /// The rendered literal. + public static string Literal( + object? value, + TypeRefSyntax declared, + string property, + string owner, + ApplicationSet applicationSet, + ICollection diagnostics) + { + var type = TypeResolver.Resolve(declared, applicationSet); + if (type.IsCollection || type.Kind == ResolvedTypeKind.Composite) + { + diagnostics.Add($"'{property}' of '{owner}' is a {(type.IsCollection ? "collection" : "composite type")}, which a stated literal cannot fill."); + return "default!"; + } + + if (type.Kind == ResolvedTypeKind.Enum && value is string member) + { + return $"{type.ClrTypeName}.{Identifiers.ToPascalCase(member)}"; + } + + var underlying = Underlying(declared, type, applicationSet); + var rendered = underlying switch + { + "string" when value is string text => CSharpCodeBuilder.StringLiteral(text), + "Guid" when value is string text => $"Guid.Parse({CSharpCodeBuilder.StringLiteral(text)})", + "DateOnly" when value is string text => $"DateOnly.Parse({CSharpCodeBuilder.StringLiteral(text)}, CultureInfo.InvariantCulture)", + "DateTimeOffset" when value is string text => $"DateTimeOffset.Parse({CSharpCodeBuilder.StringLiteral(text)}, CultureInfo.InvariantCulture)", + "bool" when value is bool boolean => boolean ? "true" : "false", + "int" when value is int or long or double or decimal => Convert.ToInt64(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + "decimal" when value is int or long or double or decimal => $"{Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture)}m", + _ => null, + }; + + if (rendered is null) + { + diagnostics.Add( + $"'{property}' of '{owner}' is stated as {Describe(value)}, which '{type.ClrTypeName}' cannot take."); + return "default!"; + } + + return rendered; + } + + /// + /// Whether rendering a literal into the given type needs System.Globalization in scope. + /// + /// The rendered literal. + /// True when the rendering parses a culture-sensitive value. + public static bool NeedsGlobalization(string rendered) => rendered.Contains("CultureInfo.InvariantCulture", StringComparison.Ordinal); + + static string? Underlying(TypeRefSyntax declared, ResolvedType type, ApplicationSet applicationSet) + { + if (type.Kind == ResolvedTypeKind.Primitive) + { + return type.ClrTypeName; + } + + if (type.Kind == ResolvedTypeKind.Concept && applicationSet.Concepts.TryGetValue(declared.Name, out var concept)) + { + return TypeResolver.Resolve(new TypeRefSyntax(concept.Type, false, false, concept.Location), applicationSet) is + { Kind: ResolvedTypeKind.Primitive } resolved ? resolved.ClrTypeName : null; + } + + return null; + } + + static string Describe(object? value) => value switch + { + null => "nothing", + string text => $"the text '{text}'", + bool boolean => boolean ? "true" : "false", + _ => $"the value '{value}'", + }; +} diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs new file mode 100644 index 0000000..bef255c --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Specifications; + +namespace Cratis.Stage.Rendering.Cratis.for_SpecificationRenderer.given; + +/// +/// A State Change slice whose command is exercised by specifications of every shape the language allows. +/// +public class a_slice_with_specifications : Specification +{ + protected CommandSyntax _command = null!; + protected LocatedSlice _slice = null!; + protected ApplicationSet _applicationSet = null!; + + void Establish() + { + var invoiceId = Property("invoiceId", "InvoiceId", isIdentifier: true); + var invoiceNumber = Property("invoiceNumber", "String"); + var registered = new EventSyntax("InvoiceRegistered", [invoiceId, invoiceNumber], SourceLocation.Start); + + _command = new CommandSyntax("RegisterInvoice", [invoiceId, invoiceNumber], null, [], [], null, SourceLocation.Start); + + var slice = new SliceSyntax( + SliceType.StateChange, "Register", [registered], [_command], [], [], [], [], [], [], [], SourceLocation.Start); + _slice = new LocatedSlice(slice, ["Billing", "Invoicing"]); + + var application = new ApplicationSyntax( + [], + [new ConceptSyntax("InvoiceId", "Uuid", [], [], SourceLocation.Start)], + [], + [new ModuleSyntax("Billing", [], [new FeatureSyntax("Invoicing", [], [slice], SourceLocation.Start)], SourceLocation.Start)], + SourceLocation.Start); + _applicationSet = new ApplicationSet([application]); + } + + protected static SpecificationSyntax Specification( + string name, + IEnumerable? given = null, + SpecificationCommandSyntax? when = null, + IEnumerable? then = null, + IEnumerable? errors = null, + IEnumerable? thenReadModels = null) => + new(name, given ?? [], when, then ?? [], errors ?? [], SourceLocation.Start, ThenReadModels: thenReadModels); + + protected static SpecificationCommandSyntax When(string command, params (string Property, object Value)[] values) => + new(command, [.. values.Select(Mapping)], SourceLocation.Start); + + protected static SpecificationEventSyntax Event(string type, params (string Property, object Value)[] values) => + new(type, [.. values.Select(Mapping)], SourceLocation.Start); + + static PropertyMappingSyntax Mapping((string Property, object Value) value) => + new(value.Property, new LiteralExpressionSyntax(value.Value, SourceLocation.Start), SourceLocation.Start); + + static PropertySyntax Property(string name, string type, bool isIdentifier = false) => + new(name, new TypeRefSyntax(type, false, false, SourceLocation.Start), SourceLocation.Start, IsIdentifier: isIdentifier); +} diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs new file mode 100644 index 0000000..2a82fb0 --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.CodeGeneration; +using Cratis.Stage.Rendering.Cratis.Renderers; +using Cratis.Stage.Rendering.Cratis.for_CratisRenderer; +using Cratis.Stage.Rendering.Cratis.Specifications; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_SpecificationRenderer; + +/// +/// Rendering a spec that reads well and does not compile is the failure mode that matters here, so the slice it +/// exercises is rendered alongside it and the pair is compiled against the real Cratis testing assemblies. +/// +public class when_rendering_a_specification : given.a_slice_with_specifications +{ + RenderedFile _appended = null!; + RenderedFile _rejected = null!; + IReadOnlyList _errors = null!; + + void Because() + { + _appended = SpecificationRenderer.Render( + Specification( + "RegisteringAnInvoice", + when: When("RegisterInvoice", ("invoiceId", "9c858901-8a57-4791-81fe-4c455b099bc9"), ("invoiceNumber", "INV-000123")), + then: [Event("InvoiceRegistered", ("invoiceNumber", "INV-000123"))]), + _command, + _slice, + _applicationSet, + "Acme"); + + _rejected = SpecificationRenderer.Render( + Specification( + "RejectingAnInvoiceWithNoNumber", + when: When("RegisterInvoice", ("invoiceId", "9c858901-8a57-4791-81fe-4c455b099bc9")), + errors: [new(null, Screenplay.Diagnostics.SourceLocation.Start)]), + _command, + _slice, + _applicationSet, + "Acme"); + + var slice = new StateChangeSliceRenderer().Render(_slice, _applicationSet, "Acme"); + var concepts = _applicationSet.Concepts.Values.Select(concept => ConceptRenderer.Render(concept, _applicationSet, "Acme")); + _errors = RenderedOutput.Errors([slice, _appended, _rejected, .. concepts]); + } + + [Fact] void should_render_specs_that_compile() => _errors.ShouldBeEmpty(); + [Fact] void should_name_the_file_for_the_behavior() => + _appended.RelativePath.EndsWith("when_registering_an_invoice.cs", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_ship_only_in_debug() => _appended.Content.StartsWith("#if DEBUG", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_exercise_the_command_through_a_scenario() => + _appended.Content.ShouldContain("readonly CommandScenario _scenario = new();"); + [Fact] void should_state_the_uuid_the_document_wrote_as_text() => + _appended.Content.ShouldContain("Guid.Parse(\"9c858901-8a57-4791-81fe-4c455b099bc9\")"); + [Fact] void should_assert_the_appended_event_against_its_event_source() => + _appended.Content.ShouldContain( + "await _scenario.ShouldHaveAppendedEvent(new EventSourceId(\"9c858901-8a57-4791-81fe-4c455b099bc9\"), @event => @event.InvoiceNumber == \"INV-000123\");"); + + // Both, deliberately: on its own ShouldNotBeSuccessful cannot tell a rejection from an unhandled exception. + [Fact] void should_assert_a_rejection_as_both_unsuccessful_and_invalid() => + _rejected.Content.ShouldContain("_result.ShouldNotBeSuccessful();"); + [Fact] void should_assert_a_rejection_has_validation_errors() => + _rejected.Content.ShouldContain("_result.ShouldHaveValidationErrors();"); + [Fact] void should_report_what_the_document_left_unstated() => + _rejected.Diagnostics.ShouldContain( + "Specification 'RejectingAnInvoiceWithNoNumber' states no value for 'invoiceNumber' of command 'RegisterInvoice' — the rendered spec constructs them as missing values."); +} diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs new file mode 100644 index 0000000..6091102 --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.Specifications; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_SpecificationRenderer; + +public class when_the_specification_cannot_be_rendered_faithfully : given.a_slice_with_specifications +{ + string? _withGiven; + string? _withReadModels; + string? _withAnotherCommand; + string? _assertingNothing; + string? _renderable; + + void Because() + { + _withGiven = SpecificationRenderer.Unrenderable( + Specification("Seeding", given: [Event("InvoiceRegistered", ("invoiceNumber", "INV-1"))], when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")]), + _command); + _withReadModels = SpecificationRenderer.Unrenderable( + Specification( + "Reading", + when: When("RegisterInvoice"), + then: [Event("InvoiceRegistered")], + thenReadModels: [new SpecificationReadModelSyntax("InvoiceList", [], SourceLocation.Start)]), + _command); + _withAnotherCommand = SpecificationRenderer.Unrenderable( + Specification("Elsewhere", when: When("CancelInvoice"), then: [Event("InvoiceRegistered")]), _command); + _assertingNothing = SpecificationRenderer.Unrenderable(Specification("Nothing", when: When("RegisterInvoice")), _command); + _renderable = SpecificationRenderer.Unrenderable( + Specification("Registering", when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")]), _command); + } + + // The document, not the target, is what makes this unrenderable — CommandScenario does support Given. + [Fact] void should_decline_a_specification_that_seeds_prior_state() => + _withGiven.ShouldContain("the document does not say which event source those events belong to"); + [Fact] void should_decline_a_specification_stating_read_model_state() => + _withReadModels.ShouldContain("no assertion in the scenario family"); + [Fact] void should_decline_a_specification_exercising_another_slice_s_command() => + _withAnotherCommand.ShouldContain("which this slice does not declare"); + [Fact] void should_decline_a_specification_that_asserts_nothing() => _assertingNothing.ShouldEqual("it asserts nothing"); + [Fact] void should_render_one_that_states_only_what_it_exercises_and_expects() => _renderable.ShouldBeNull(); +} diff --git a/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs b/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs index 18325f9..0ae119c 100644 --- a/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs +++ b/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs @@ -16,9 +16,9 @@ public class when_reporting_what_a_slice_declares : a_slice_declaring_every_fami void Because() => UnrenderedConstructs.Report(_builder, _slice, RenderedConstructs.None, _diagnostics); - [Fact] void should_report_every_family_it_declares() => _diagnostics.Count.ShouldEqual(11); + [Fact] void should_report_every_family_it_declares() => _diagnostics.Count.ShouldEqual(10); [Fact] void should_note_every_family_in_the_emitted_file() => - _builder.ToString().Split('\n').Count(line => line.StartsWith("// TODO:", StringComparison.Ordinal)).ShouldEqual(11); + _builder.ToString().Split('\n').Count(line => line.StartsWith("// TODO:", StringComparison.Ordinal)).ShouldEqual(10); [Fact] void should_report_the_command() => _diagnostics.ShouldContain( "Slice 'Summary' declares 1 command declaration(s) with no rendered equivalent — neither its input, the events it " + @@ -55,8 +55,11 @@ [Fact] void should_report_the_screens() => [Fact] void should_report_the_captures() => _diagnostics.ShouldContain( "Slice 'Summary' declares 1 capture declaration(s) with no rendered equivalent — no ingestion of the captured source is rendered."); - [Fact] void should_report_the_specifications() => - _diagnostics.ShouldContain( + + // Specifications are not in this list any more: each one is rendered into its own file, or says on its own + // why it could not be. Counting them here would report a rendered specification as dropped. + [Fact] void should_not_report_the_specifications() => + _diagnostics.ShouldNotContain( "Slice 'Summary' declares 1 specification declaration(s) with no rendered equivalent — no specs are rendered for the " + "generated application."); } From e541b5fc98c3653d1e2ab1dc7b1066724b4cf643 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 18:48:52 +0200 Subject: [PATCH 5/7] Compile a rendered specification, rather than an empty file The spec that compiles rendered output parsed without DEBUG defined, so every rendered specification - which sits entirely inside #if DEBUG - compiled to nothing and the assertion passed on an empty compilation unit. It proved nothing about the thing it was written for. Defining the symbol makes it real, and it immediately found what it was blind to: a stated date renders as a parse against the invariant culture, and nothing emitted the using it needs. The renderer now decides that from what it rendered. --- .../Specifications/SpecificationRenderer.cs | 11 ++++++++++- .../for_CratisRenderer/RenderedOutput.cs | 8 ++++++-- .../given/a_slice_with_specifications.cs | 5 +++-- .../when_rendering_a_specification.cs | 14 ++++++++++++-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs index dfdb71f..f7a2285 100644 --- a/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs +++ b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs @@ -116,8 +116,17 @@ public static RenderedFile Render( builder.EndBlock(); + // Decided from the rendered content rather than predicted: a date or timestamp anywhere in the values or + // the assertions renders as a parse against the invariant culture, and only the emitted text knows whether + // one is there. + var content = builder.ToString(); + if (SpecificationValues.NeedsGlobalization(content)) + { + content = builder.Using("System.Globalization").ToString(); + } + var path = new List(SliceNaming.FolderPath(slice.FullPath)) { $"{name}.cs" }; - return new RenderedFile(Path.Combine([.. path]), Conditional(builder.ToString())) { Diagnostics = diagnostics }; + return new RenderedFile(Path.Combine([.. path]), Conditional(content)) { Diagnostics = diagnostics }; } /// diff --git a/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs b/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs index 3c0cf3b..69d7744 100644 --- a/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs +++ b/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs @@ -76,9 +76,13 @@ internal static class RenderedOutput /// The compilation errors, empty when the output compiles. public static IReadOnlyList Errors(IEnumerable files) { + // DEBUG has to be defined or a rendered specification compiles to nothing: the whole file sits inside + // '#if DEBUG', and a parse without the symbol drops it silently — the assertion would then pass on an + // empty compilation unit and prove nothing about the spec it was written for. + var parseOptions = new CSharpParseOptions(preprocessorSymbols: ["DEBUG"]); var trees = files - .Select(file => CSharpSyntaxTree.ParseText(file.Content, path: file.RelativePath)) - .Prepend(CSharpSyntaxTree.ParseText(ImplicitUsings, path: "GlobalUsings.g.cs")); + .Select(file => CSharpSyntaxTree.ParseText(file.Content, parseOptions, path: file.RelativePath)) + .Prepend(CSharpSyntaxTree.ParseText(ImplicitUsings, parseOptions, path: "GlobalUsings.g.cs")); var compilation = CSharpCompilation.Create( "RenderedApplication", diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs index bef255c..a873405 100644 --- a/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs @@ -21,9 +21,10 @@ void Establish() { var invoiceId = Property("invoiceId", "InvoiceId", isIdentifier: true); var invoiceNumber = Property("invoiceNumber", "String"); - var registered = new EventSyntax("InvoiceRegistered", [invoiceId, invoiceNumber], SourceLocation.Start); + var dueDate = Property("dueDate", "Date"); + var registered = new EventSyntax("InvoiceRegistered", [invoiceId, invoiceNumber, dueDate], SourceLocation.Start); - _command = new CommandSyntax("RegisterInvoice", [invoiceId, invoiceNumber], null, [], [], null, SourceLocation.Start); + _command = new CommandSyntax("RegisterInvoice", [invoiceId, invoiceNumber, dueDate], null, [], [], null, SourceLocation.Start); var slice = new SliceSyntax( SliceType.StateChange, "Register", [registered], [_command], [], [], [], [], [], [], [], SourceLocation.Start); diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs index 2a82fb0..ca942d1 100644 --- a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs @@ -25,7 +25,11 @@ void Because() _appended = SpecificationRenderer.Render( Specification( "RegisteringAnInvoice", - when: When("RegisterInvoice", ("invoiceId", "9c858901-8a57-4791-81fe-4c455b099bc9"), ("invoiceNumber", "INV-000123")), + when: When( + "RegisterInvoice", + ("invoiceId", "9c858901-8a57-4791-81fe-4c455b099bc9"), + ("invoiceNumber", "INV-000123"), + ("dueDate", "2026-08-13")), then: [Event("InvoiceRegistered", ("invoiceNumber", "INV-000123"))]), _command, _slice, @@ -55,6 +59,12 @@ [Fact] void should_exercise_the_command_through_a_scenario() => _appended.Content.ShouldContain("readonly CommandScenario _scenario = new();"); [Fact] void should_state_the_uuid_the_document_wrote_as_text() => _appended.Content.ShouldContain("Guid.Parse(\"9c858901-8a57-4791-81fe-4c455b099bc9\")"); + + // A date renders as a parse against the invariant culture, which needs a using the renderer only knows to + // emit by looking at what it rendered. + [Fact] void should_state_the_date_the_document_wrote_as_text() => + _appended.Content.ShouldContain("DateOnly.Parse(\"2026-08-13\", CultureInfo.InvariantCulture)"); + [Fact] void should_import_what_parsing_it_needs() => _appended.Content.ShouldContain("using System.Globalization;"); [Fact] void should_assert_the_appended_event_against_its_event_source() => _appended.Content.ShouldContain( "await _scenario.ShouldHaveAppendedEvent(new EventSourceId(\"9c858901-8a57-4791-81fe-4c455b099bc9\"), @event => @event.InvoiceNumber == \"INV-000123\");"); @@ -66,5 +76,5 @@ [Fact] void should_assert_a_rejection_has_validation_errors() => _rejected.Content.ShouldContain("_result.ShouldHaveValidationErrors();"); [Fact] void should_report_what_the_document_left_unstated() => _rejected.Diagnostics.ShouldContain( - "Specification 'RejectingAnInvoiceWithNoNumber' states no value for 'invoiceNumber' of command 'RegisterInvoice' — the rendered spec constructs them as missing values."); + "Specification 'RejectingAnInvoiceWithNoNumber' states no value for 'invoiceNumber', 'dueDate' of command 'RegisterInvoice' — the rendered spec constructs them as missing values."); } From 3cfb3a79cf306ed407b0a353766c721e2a8cd16b Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 18:52:22 +0200 Subject: [PATCH 6/7] Compile the branch that names no event source SpecificationAssertions.Of renders EventSourceId.Unspecified when the specification states no value for the command's identifier, and nothing had ever compiled that branch - the fixture always stated one. It does now, which is the only way to know the rendering is real rather than plausible. --- .../when_rendering_a_specification.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs index ca942d1..9a08d72 100644 --- a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs @@ -18,6 +18,7 @@ public class when_rendering_a_specification : given.a_slice_with_specifications { RenderedFile _appended = null!; RenderedFile _rejected = null!; + RenderedFile _withoutIdentity = null!; IReadOnlyList _errors = null!; void Because() @@ -46,9 +47,21 @@ void Because() _applicationSet, "Acme"); + // The identifier is deliberately unstated here: it is the branch that renders EventSourceId.Unspecified, + // and it is only ever exercised by compiling it. + _withoutIdentity = SpecificationRenderer.Render( + Specification( + "RegisteringWithoutSayingWhich", + when: When("RegisterInvoice", ("invoiceNumber", "INV-000124")), + then: [Event("InvoiceRegistered", ("invoiceNumber", "INV-000124"))]), + _command, + _slice, + _applicationSet, + "Acme"); + var slice = new StateChangeSliceRenderer().Render(_slice, _applicationSet, "Acme"); var concepts = _applicationSet.Concepts.Values.Select(concept => ConceptRenderer.Render(concept, _applicationSet, "Acme")); - _errors = RenderedOutput.Errors([slice, _appended, _rejected, .. concepts]); + _errors = RenderedOutput.Errors([slice, _appended, _rejected, _withoutIdentity, .. concepts]); } [Fact] void should_render_specs_that_compile() => _errors.ShouldBeEmpty(); @@ -74,6 +87,12 @@ [Fact] void should_assert_a_rejection_as_both_unsuccessful_and_invalid() => _rejected.Content.ShouldContain("_result.ShouldNotBeSuccessful();"); [Fact] void should_assert_a_rejection_has_validation_errors() => _rejected.Content.ShouldContain("_result.ShouldHaveValidationErrors();"); + [Fact] void should_assert_against_no_event_source_when_the_document_names_none() => + _withoutIdentity.Content.ShouldContain("ShouldHaveAppendedEvent(EventSourceId.Unspecified"); + [Fact] void should_say_the_event_source_was_never_named() => + _withoutIdentity.Diagnostics.ShouldContain( + "The specification states no value for 'invoiceId', which is what says which event source the appended events belong to."); + [Fact] void should_report_what_the_document_left_unstated() => _rejected.Diagnostics.ShouldContain( "Specification 'RejectingAnInvoiceWithNoNumber' states no value for 'invoiceNumber', 'dueDate' of command 'RegisterInvoice' — the rendered spec constructs them as missing values."); From ce77bb6e8d2288820ea4182c413d2e308f516bf6 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 18:58:11 +0200 Subject: [PATCH 7/7] Resolve what a rendered specification names, and stop three silent drops A rendered spec sits in a child namespace of its slice, so it finds the command it exercises and nothing else. It now resolves imports the way every other renderer does - an event a sibling slice declares, or a concept placed above the slice, compiled against nothing before. Alongside that, five things the review found: - a fanout stating one event type twice declared the same fact twice - a specification expecting both a rejection and appended events rendered only the rejection and dropped the events without saying so; it is declined instead, because a rejected command appends nothing - the event source id was rendered from the document's raw text while the command constructed its identity through Guid.Parse, so any casing difference asserted against a stream the command never appended to - a stated 'then' value the event does not declare, or that is not a literal, was dropped from the predicate silently - a stated 1.5 for an Int rendered as 2, and a value outside int range rendered a literal the generated application could not compile --- Source/Rendering.Cratis/CratisRenderer.cs | 10 ++- .../Specifications/SpecificationAssertions.cs | 32 ++++++- .../Specifications/SpecificationRenderer.cs | 53 +++++++++-- .../Specifications/SpecificationValues.cs | 17 +++- .../given/a_slice_with_specifications.cs | 2 + .../when_rendering_a_specification.cs | 7 +- ...ification_cannot_be_rendered_faithfully.cs | 23 +++-- ...pecification_names_what_lives_elsewhere.cs | 90 +++++++++++++++++++ 8 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_names_what_lives_elsewhere.cs diff --git a/Source/Rendering.Cratis/CratisRenderer.cs b/Source/Rendering.Cratis/CratisRenderer.cs index 1c7c12f..19cf97d 100644 --- a/Source/Rendering.Cratis/CratisRenderer.cs +++ b/Source/Rendering.Cratis/CratisRenderer.cs @@ -224,17 +224,19 @@ async Task RenderSlice(LocatedSlice slice, ApplicationSet applicationSet, string async Task RenderSpecifications( LocatedSlice slice, ApplicationSet applicationSet, string rootNamespace, DirectoryInfo targetDirectory, TextWriter output, TextWriter error) { - var command = slice.Slice.Commands.FirstOrDefault(); - foreach (var specification in slice.Slice.Specifications) { - if (SpecificationRenderer.Unrenderable(specification, command) is { } reason) + if (SpecificationRenderer.Unrenderable(specification, slice.Slice) is { } reason) { await error.WriteLineAsync($"Specification '{specification.Name}' is not rendered — {reason}."); continue; } - await WriteFile(SpecificationRenderer.Render(specification, command!, slice, applicationSet, rootNamespace), targetDirectory, output, error); + await WriteFile( + SpecificationRenderer.Render(specification, slice.Slice.Commands.First(), slice, applicationSet, rootNamespace), + targetDirectory, + output, + error); } } diff --git a/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs b/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs index aa27261..9fcf849 100644 --- a/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs +++ b/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs @@ -38,7 +38,7 @@ public static string Of( } var stated = when.Values.FirstOrDefault(value => string.Equals(value.Property, identifier.Name, StringComparison.OrdinalIgnoreCase)); - if (stated?.Source is not LiteralExpressionSyntax { Value: string text }) + if (stated is null) { diagnostics.Add( $"The specification states no value for '{identifier.Name}', which is what says which event source " + @@ -46,7 +46,20 @@ public static string Of( return "EventSourceId.Unspecified"; } - return $"new EventSourceId({CodeGeneration.CSharpCodeBuilder.StringLiteral(text)})"; + var value = SpecificationValues.For(identifier, when.Values, command.Name, applicationSet, diagnostics); + if (value == "default!") + { + diagnostics.Add( + $"The value stated for '{identifier.Name}' cannot be rendered, so the appended events are asserted " + + "against no event source."); + return "EventSourceId.Unspecified"; + } + + // Rendered through the same conversion the command's own argument takes, then to string. Arc resolves the + // event source id from the constructed identity, and a Guid's canonical form is lowercase - asserting + // against the document's raw text would never match an id the command appended under, whatever casing the + // document happened to use. + return $"new EventSourceId({value}.ToString())"; } /// @@ -66,9 +79,19 @@ public static string Predicate(SpecificationEventSyntax @event, ApplicationSet a return string.Empty; } - var comparisons = @event.Values + var pairs = @event.Values .Select(value => (Value: value, Property: declared.Properties.FirstOrDefault( property => string.Equals(property.Name, value.Property, StringComparison.OrdinalIgnoreCase)))) + .ToArray(); + + foreach (var undeclared in pairs.Where(pair => pair.Property is null)) + { + diagnostics.Add( + $"The specification states '{undeclared.Value.Property}' on '{@event.EventType}', which the event does " + + "not declare — it is not asserted."); + } + + var comparisons = pairs .Where(pair => pair.Property is not null) .Select(pair => Comparison(pair.Value, pair.Property!, @event.EventType, applicationSet, diagnostics)) .Where(comparison => comparison is not null) @@ -86,6 +109,9 @@ public static string Predicate(SpecificationEventSyntax @event, ApplicationSet a { if (value.Source is not LiteralExpressionSyntax literal) { + diagnostics.Add( + $"'{property.Name}' of '{eventType}' is stated as a {value.Source.GetType().Name}, which an assertion " + + "cannot compare against — it is not asserted."); return null; } diff --git a/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs index f7a2285..f44b3cf 100644 --- a/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs +++ b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs @@ -31,7 +31,7 @@ public static class SpecificationRenderer /// Says why a specification cannot be rendered faithfully, or when it can. /// /// The specification to consider. - /// The command the slice declares, if any. + /// The slice the specification belongs to. /// The reason, or . /// /// The given case is the interesting one, and it is not a gap in the target: CommandScenario @@ -41,8 +41,9 @@ public static class SpecificationRenderer /// InvoiceRegistered for a different invoice than the one the command registers, and seeding it /// against the command's id would make the spec assert something else entirely. /// - public static string? Unrenderable(SpecificationSyntax specification, CommandSyntax? command) + public static string? Unrenderable(SpecificationSyntax specification, SliceSyntax slice) { + var command = slice.Commands.FirstOrDefault(); if (command is null || specification.When is null) { return "it exercises no command, and only a command scenario is rendered"; @@ -50,7 +51,11 @@ public static class SpecificationRenderer if (!string.Equals(specification.When.CommandType, command.Name, StringComparison.OrdinalIgnoreCase)) { - return $"it exercises '{specification.When.CommandType}', which this slice does not declare"; + // The slice may well declare it — only the first command is rendered, so anything else has no type to + // exercise. Saying "this slice does not declare it" would send a reader to the wrong document. + return slice.Commands.Any(candidate => string.Equals(candidate.Name, specification.When.CommandType, StringComparison.OrdinalIgnoreCase)) + ? $"it exercises '{specification.When.CommandType}', and only the first command a slice declares is rendered" + : $"it exercises '{specification.When.CommandType}', which this slice does not declare"; } if (specification.Given.Any()) @@ -65,6 +70,12 @@ public static class SpecificationRenderer return "it states read model state, which has no assertion in the scenario family"; } + if (specification.ThenEvents.Any() && specification.ThenErrors.Any()) + { + return "it expects both a rejection and appended events, and a rejected command appends nothing — " + + "rendering either half would assert something the document did not state"; + } + if (!specification.ThenEvents.Any() && !specification.ThenErrors.Any()) { return "it asserts nothing"; @@ -92,12 +103,22 @@ public static RenderedFile Render( var diagnostics = new List(); var name = Behavior(specification.Name); var commandType = Identifiers.ToPascalCase(command.Name); + var ownNamespace = $"{SliceNaming.Namespace(rootNamespace, slice.FullPath)}.{name}"; var builder = new CSharpCodeBuilder() - .Namespace($"{SliceNaming.Namespace(rootNamespace, slice.FullPath)}.{name}") + .Namespace(ownNamespace) .Using("Cratis.Arc.Testing.Commands") .Using("Cratis.Specifications") .Using("Xunit"); + // Resolved the way every other renderer resolves: a spec sits in a child namespace of its slice, so the + // command it exercises is found without an import but an event another slice declares - or a concept + // placed above the slice - is not. + foreach (var @namespace in ReferencedNamespaces.Resolve( + ReferencedNames(specification, command), applicationSet, rootNamespace, ownNamespace)) + { + builder.Using(@namespace); + } + builder.BlankLine().OpenBlock($"public class {name} : Specification") .Line($"readonly CommandScenario<{commandType}> _scenario = new();") .Line("CommandResult _result = null!;") @@ -163,13 +184,21 @@ static void RenderAppends( builder.Using("Cratis.Arc.Chronicle.Testing.Commands").Line("[Fact] void should_succeed() => _result.ShouldBeSuccessful();"); var identifier = SpecificationAssertions.Of(specification.When!, command, applicationSet, diagnostics); + var events = specification.ThenEvents.ToArray(); - foreach (var @event in specification.ThenEvents) + foreach (var (@event, index) in events.Select((@event, index) => (@event, index))) { var eventType = Identifiers.ToPascalCase(@event.EventType); var predicate = SpecificationAssertions.Predicate(@event, applicationSet, diagnostics); + + // A fanout states the same event type more than once, each with its own values. The facts have to be + // named apart or the rendered class declares the same member twice. + var occurrence = events.Count(other => string.Equals(other.EventType, @event.EventType, StringComparison.OrdinalIgnoreCase)) > 1 + ? $"_{events.Take(index + 1).Count(other => string.Equals(other.EventType, @event.EventType, StringComparison.OrdinalIgnoreCase))}" + : string.Empty; + builder.Line( - $"[Fact] async Task should_have_appended_{Identifiers.ToSnakeCase(@event.EventType)}() => " + + $"[Fact] async Task should_have_appended_{Identifiers.ToSnakeCase(@event.EventType)}{occurrence}() => " + $"await _scenario.ShouldHaveAppendedEvent<{commandType}, {eventType}>({identifier}{predicate});"); } } @@ -207,6 +236,18 @@ static string Arguments( return string.Join(", ", command.Properties.Select(property => SpecificationValues.For(property, when.Values, command.Name, applicationSet, diagnostics))); } + /// + /// Every Screenplay name the rendered spec references — the command, the events it expects, and the types + /// their values are stated as. + /// + /// The specification being rendered. + /// The command it exercises. + /// The referenced names. + static IEnumerable ReferencedNames(SpecificationSyntax specification, CommandSyntax command) => + specification.ThenEvents.Select(@event => @event.EventType) + .Append(command.Name) + .Concat(command.Properties.Select(property => property.Type.Name)); + /// /// Turns the specification's name into the behavior the folder and class read as — RegisteringADraftInvoice /// becomes when_registering_a_draft_invoice. diff --git a/Source/Rendering.Cratis/Specifications/SpecificationValues.cs b/Source/Rendering.Cratis/Specifications/SpecificationValues.cs index cee8846..019b980 100644 --- a/Source/Rendering.Cratis/Specifications/SpecificationValues.cs +++ b/Source/Rendering.Cratis/Specifications/SpecificationValues.cs @@ -91,7 +91,7 @@ public static string Literal( "DateOnly" when value is string text => $"DateOnly.Parse({CSharpCodeBuilder.StringLiteral(text)}, CultureInfo.InvariantCulture)", "DateTimeOffset" when value is string text => $"DateTimeOffset.Parse({CSharpCodeBuilder.StringLiteral(text)}, CultureInfo.InvariantCulture)", "bool" when value is bool boolean => boolean ? "true" : "false", - "int" when value is int or long or double or decimal => Convert.ToInt64(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + "int" when value is int or long or double or decimal && Integral(value) is { } integer => integer.ToString(CultureInfo.InvariantCulture), "decimal" when value is int or long or double or decimal => $"{Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture)}m", _ => null, }; @@ -113,6 +113,21 @@ public static string Literal( /// True when the rendering parses a culture-sensitive value. public static bool NeedsGlobalization(string rendered) => rendered.Contains("CultureInfo.InvariantCulture", StringComparison.Ordinal); + /// + /// The value as an , or when it is not one. A stated 1.5 is not + /// the integer 2, and a value outside the range is not an integer at all — rounding either would put a value + /// in the rendered output that the document never stated. + /// + /// The stated literal. + /// The integer, or . + static int? Integral(object value) + { + var number = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + return number == decimal.Truncate(number) && number >= int.MinValue && number <= int.MaxValue + ? (int)number + : null; + } + static string? Underlying(TypeRefSyntax declared, ResolvedType type, ApplicationSet applicationSet) { if (type.Kind == ResolvedTypeKind.Primitive) diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs index a873405..5f061d9 100644 --- a/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs @@ -14,6 +14,7 @@ namespace Cratis.Stage.Rendering.Cratis.for_SpecificationRenderer.given; public class a_slice_with_specifications : Specification { protected CommandSyntax _command = null!; + protected SliceSyntax _sliceSyntax = null!; protected LocatedSlice _slice = null!; protected ApplicationSet _applicationSet = null!; @@ -28,6 +29,7 @@ void Establish() var slice = new SliceSyntax( SliceType.StateChange, "Register", [registered], [_command], [], [], [], [], [], [], [], SourceLocation.Start); + _sliceSyntax = slice; _slice = new LocatedSlice(slice, ["Billing", "Invoicing"]); var application = new ApplicationSyntax( diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs index 9a08d72..32032f9 100644 --- a/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs @@ -80,7 +80,12 @@ [Fact] void should_state_the_date_the_document_wrote_as_text() => [Fact] void should_import_what_parsing_it_needs() => _appended.Content.ShouldContain("using System.Globalization;"); [Fact] void should_assert_the_appended_event_against_its_event_source() => _appended.Content.ShouldContain( - "await _scenario.ShouldHaveAppendedEvent(new EventSourceId(\"9c858901-8a57-4791-81fe-4c455b099bc9\"), @event => @event.InvoiceNumber == \"INV-000123\");"); + "await _scenario.ShouldHaveAppendedEvent(new EventSourceId(Guid.Parse(\"9c858901-8a57-4791-81fe-4c455b099bc9\").ToString()), @event => @event.InvoiceNumber == \"INV-000123\");"); + + // The command constructs its identity through Guid.Parse, whose ToString is canonical lowercase. Asserting + // against the document's raw text would never match an id the command appended under. + [Fact] void should_assert_against_the_event_source_the_command_appends_under() => + _appended.Content.ShouldNotContain("new EventSourceId(\"9c858901"); // Both, deliberately: on its own ShouldNotBeSuccessful cannot tell a rejection from an unhandled exception. [Fact] void should_assert_a_rejection_as_both_unsuccessful_and_invalid() => diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs index 6091102..aad6d42 100644 --- a/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.cs @@ -15,25 +15,33 @@ public class when_the_specification_cannot_be_rendered_faithfully : given.a_slic string? _withReadModels; string? _withAnotherCommand; string? _assertingNothing; + string? _expectingBoth; string? _renderable; void Because() { _withGiven = SpecificationRenderer.Unrenderable( Specification("Seeding", given: [Event("InvoiceRegistered", ("invoiceNumber", "INV-1"))], when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")]), - _command); + _sliceSyntax); _withReadModels = SpecificationRenderer.Unrenderable( Specification( "Reading", when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")], thenReadModels: [new SpecificationReadModelSyntax("InvoiceList", [], SourceLocation.Start)]), - _command); + _sliceSyntax); _withAnotherCommand = SpecificationRenderer.Unrenderable( - Specification("Elsewhere", when: When("CancelInvoice"), then: [Event("InvoiceRegistered")]), _command); - _assertingNothing = SpecificationRenderer.Unrenderable(Specification("Nothing", when: When("RegisterInvoice")), _command); + Specification("Elsewhere", when: When("CancelInvoice"), then: [Event("InvoiceRegistered")]), _sliceSyntax); + _assertingNothing = SpecificationRenderer.Unrenderable(Specification("Nothing", when: When("RegisterInvoice")), _sliceSyntax); + _expectingBoth = SpecificationRenderer.Unrenderable( + Specification( + "Both", + when: When("RegisterInvoice"), + then: [Event("InvoiceRegistered")], + errors: [new(null, SourceLocation.Start)]), + _sliceSyntax); _renderable = SpecificationRenderer.Unrenderable( - Specification("Registering", when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")]), _command); + Specification("Registering", when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")]), _sliceSyntax); } // The document, not the target, is what makes this unrenderable — CommandScenario does support Given. @@ -44,5 +52,10 @@ [Fact] void should_decline_a_specification_stating_read_model_state() => [Fact] void should_decline_a_specification_exercising_another_slice_s_command() => _withAnotherCommand.ShouldContain("which this slice does not declare"); [Fact] void should_decline_a_specification_that_asserts_nothing() => _assertingNothing.ShouldEqual("it asserts nothing"); + + // A rejected command appends nothing, so a document stating both contradicts itself. Rendering either half + // would drop the other silently, which is the one thing this renderer is written not to do. + [Fact] void should_decline_a_specification_expecting_both_a_rejection_and_events() => + _expectingBoth.ShouldContain("a rejected command appends nothing"); [Fact] void should_render_one_that_states_only_what_it_exercises_and_expects() => _renderable.ShouldBeNull(); } diff --git a/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_names_what_lives_elsewhere.cs b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_names_what_lives_elsewhere.cs new file mode 100644 index 0000000..b9f17bd --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_names_what_lives_elsewhere.cs @@ -0,0 +1,90 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.CodeGeneration; +using Cratis.Stage.Rendering.Cratis.for_CratisRenderer; +using Cratis.Stage.Rendering.Cratis.Renderers; +using Cratis.Stage.Rendering.Cratis.Specifications; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_SpecificationRenderer; + +/// +/// A rendered spec sits in a child namespace of its slice, so it finds the command without an import and finds +/// nothing else. An event another slice declares, and a fanout stating one event type twice, are the two shapes +/// that turn that into output which does not compile — so both are compiled here rather than inspected. +/// +public class when_the_specification_names_what_lives_elsewhere : Specification +{ + RenderedFile _rendered = null!; + IReadOnlyList _errors = null!; + + void Because() + { + var invoiceId = Property("invoiceId", "InvoiceId", isIdentifier: true); + var lineNumber = Property("lineNumber", "Int"); + + // Declared by a sibling slice, which is where the rendered spec cannot see it from. + var lineAdded = new EventSyntax("InvoiceLineAdded", [invoiceId, lineNumber], SourceLocation.Start); + var linesSlice = new SliceSyntax( + SliceType.StateChange, "AddLines", [lineAdded], [], [], [], [], [], [], [], [], SourceLocation.Start); + + var command = new CommandSyntax("RegisterInvoice", [invoiceId], null, [], [], null, SourceLocation.Start); + var registerSlice = new SliceSyntax( + SliceType.StateChange, "Register", [], [command], [], [], [], [], [], [], [], SourceLocation.Start); + var located = new LocatedSlice(registerSlice, ["Billing", "Invoicing"]); + + var application = new ApplicationSyntax( + [], + [new ConceptSyntax("InvoiceId", "Uuid", [], [], SourceLocation.Start)], + [], + [new ModuleSyntax( + "Billing", + [], + [new FeatureSyntax("Invoicing", [], [registerSlice, linesSlice], SourceLocation.Start)], + SourceLocation.Start)], + SourceLocation.Start); + var applicationSet = new ApplicationSet([application]); + + var specification = new Screenplay.Syntax.Specifications.SpecificationSyntax( + "AddingTwoLines", + [], + new Screenplay.Syntax.Specifications.SpecificationCommandSyntax( + "RegisterInvoice", + [Value("invoiceId", "9c858901-8a57-4791-81fe-4c455b099bc9")], + SourceLocation.Start), + [ + Then("InvoiceLineAdded", ("lineNumber", 1)), + Then("InvoiceLineAdded", ("lineNumber", 2)), + ], + [], + SourceLocation.Start); + + _rendered = SpecificationRenderer.Render(specification, command, located, applicationSet, "Acme"); + + var slices = new[] { located, new LocatedSlice(linesSlice, ["Billing", "Invoicing"]) } + .Select(slice => new StateChangeSliceRenderer().Render(slice, applicationSet, "Acme")); + var concepts = applicationSet.Concepts.Values.Select(concept => ConceptRenderer.Render(concept, applicationSet, "Acme")); + _errors = RenderedOutput.Errors([_rendered, .. slices, .. concepts]); + } + + [Fact] void should_render_output_that_compiles() => _errors.ShouldBeEmpty(); + [Fact] void should_import_the_slice_that_declares_the_event() => + _rendered.Content.ShouldContain("using Acme.Billing.Invoicing.AddLines;"); + [Fact] void should_name_the_two_facts_apart() => + _rendered.Content.ShouldContain("should_have_appended_invoice_line_added_1()"); + [Fact] void should_name_the_second_fact_for_its_occurrence() => + _rendered.Content.ShouldContain("should_have_appended_invoice_line_added_2()"); + + static PropertySyntax Property(string name, string type, bool isIdentifier = false) => + new(name, new TypeRefSyntax(type, false, false, SourceLocation.Start), SourceLocation.Start, IsIdentifier: isIdentifier); + + static PropertyMappingSyntax Value(string property, object value) => + new(property, new LiteralExpressionSyntax(value, SourceLocation.Start), SourceLocation.Start); + + static Screenplay.Syntax.Specifications.SpecificationEventSyntax Then(string type, params (string Property, object Value)[] values) => + new(type, [.. values.Select(value => Value(value.Property, value.Value))], SourceLocation.Start); +}