diff --git a/Directory.Packages.props b/Directory.Packages.props index 9bb3e62..bfdbdbd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,14 @@ + + + + + + + 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..19cf97d 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; @@ -23,9 +24,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 +44,7 @@ public static CratisRenderer CreateDefault() [SliceType.Translate] = reactorRenderer, }; - return new CratisRenderer(new TemplateEngineProjectScaffolder(), sliceRenderers, new LocalFileSystemOutput()); + return new CratisRenderer(new TargetDirectoryScaffolder(), sliceRenderers, new LocalFileSystemOutput()); } /// @@ -196,6 +202,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 +210,36 @@ 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) + { + foreach (var specification in slice.Slice.Specifications) + { + if (SpecificationRenderer.Unrenderable(specification, slice.Slice) is { } reason) + { + await error.WriteLineAsync($"Specification '{specification.Name}' is not rendered — {reason}."); + continue; + } + + await WriteFile( + SpecificationRenderer.Render(specification, slice.Slice.Commands.First(), 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..32bd131 100644 --- a/Source/Rendering.Cratis/Rendering.Cratis.csproj +++ b/Source/Rendering.Cratis/Rendering.Cratis.csproj @@ -8,24 +8,35 @@ - - - - - + - - + + + + + + + + + diff --git a/Source/Rendering.Cratis/Scaffolding/TargetDirectoryScaffolder.cs b/Source/Rendering.Cratis/Scaffolding/TargetDirectoryScaffolder.cs new file mode 100644 index 0000000..1bdf9ef --- /dev/null +++ b/Source/Rendering.Cratis/Scaffolding/TargetDirectoryScaffolder.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Stage.Rendering.Cratis.Scaffolding; + +/// +/// Makes sure the target directory exists, and scaffolds nothing into it. +/// +/// +/// +/// The default, because rendering into a project that already exists is the common case — regenerating from +/// a document that has moved on. Rendering needs somewhere to write and nothing more; a rendered application +/// is 33 files for a document the size of the language's own invoicing.play, and every one of them +/// lands here whether or not a project file surrounds them. +/// +/// +/// Scaffolding a project around them is a separate job, and an expensive one to carry: it needs the template +/// engine, which brings the whole NuGet client with it, which is what made this package impossible to host +/// beside anything that uses MSBuild (Cratis/Stage#34). +/// A caller that wants it takes Cratis.Stage.Rendering.Cratis.Scaffolding and passes its +/// TemplateEngineProjectScaffolder in. +/// +/// +public class TargetDirectoryScaffolder : IProjectScaffolder +{ + /// + public Task EnsureScaffolded(DirectoryInfo targetDirectory, string projectName, TextWriter output) + { + targetDirectory.Create(); + return Task.FromResult(false); + } +} diff --git a/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs b/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs new file mode 100644 index 0000000..9fcf849 --- /dev/null +++ b/Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs @@ -0,0 +1,121 @@ +// 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 is null) + { + 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"; + } + + 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())"; + } + + /// + /// 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 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) + .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) + { + 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; + } + + 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..f44b3cf --- /dev/null +++ b/Source/Rendering.Cratis/Specifications/SpecificationRenderer.cs @@ -0,0 +1,260 @@ +// 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 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 + /// 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, 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"; + } + + if (!string.Equals(specification.When.CommandType, command.Name, StringComparison.OrdinalIgnoreCase)) + { + // 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()) + { + 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 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"; + } + + 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 ownNamespace = $"{SliceNaming.Namespace(rootNamespace, slice.FullPath)}.{name}"; + var builder = new CSharpCodeBuilder() + .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!;") + .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(); + + // 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(content)) { 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); + var events = specification.ThenEvents.ToArray(); + + 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)}{occurrence}() => " + + $"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))); + } + + /// + /// 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. + /// + /// 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..019b980 --- /dev/null +++ b/Source/Rendering.Cratis/Specifications/SpecificationValues.cs @@ -0,0 +1,154 @@ +// 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 && 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, + }; + + 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); + + /// + /// 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) + { + 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_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 new file mode 100644 index 0000000..5f061d9 --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/given/a_slice_with_specifications.cs @@ -0,0 +1,64 @@ +// 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 SliceSyntax _sliceSyntax = null!; + protected LocatedSlice _slice = null!; + protected ApplicationSet _applicationSet = null!; + + void Establish() + { + var invoiceId = Property("invoiceId", "InvoiceId", isIdentifier: true); + var invoiceNumber = Property("invoiceNumber", "String"); + var dueDate = Property("dueDate", "Date"); + var registered = new EventSyntax("InvoiceRegistered", [invoiceId, invoiceNumber, dueDate], SourceLocation.Start); + + _command = new CommandSyntax("RegisterInvoice", [invoiceId, invoiceNumber, dueDate], null, [], [], null, SourceLocation.Start); + + var slice = new SliceSyntax( + SliceType.StateChange, "Register", [registered], [_command], [], [], [], [], [], [], [], SourceLocation.Start); + _sliceSyntax = slice; + _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..32032f9 --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_rendering_a_specification.cs @@ -0,0 +1,104 @@ +// 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!; + RenderedFile _withoutIdentity = null!; + IReadOnlyList _errors = null!; + + void Because() + { + _appended = SpecificationRenderer.Render( + Specification( + "RegisteringAnInvoice", + when: When( + "RegisterInvoice", + ("invoiceId", "9c858901-8a57-4791-81fe-4c455b099bc9"), + ("invoiceNumber", "INV-000123"), + ("dueDate", "2026-08-13")), + 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"); + + // 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, _withoutIdentity, .. 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\")"); + + // 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(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() => + _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."); +} 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..aad6d42 --- /dev/null +++ b/Source/Rendering.Cratis/for_SpecificationRenderer/when_the_specification_cannot_be_rendered_faithfully.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.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? _expectingBoth; + string? _renderable; + + void Because() + { + _withGiven = SpecificationRenderer.Unrenderable( + Specification("Seeding", given: [Event("InvoiceRegistered", ("invoiceNumber", "INV-1"))], when: When("RegisterInvoice"), then: [Event("InvoiceRegistered")]), + _sliceSyntax); + _withReadModels = SpecificationRenderer.Unrenderable( + Specification( + "Reading", + when: When("RegisterInvoice"), + then: [Event("InvoiceRegistered")], + thenReadModels: [new SpecificationReadModelSyntax("InvoiceList", [], SourceLocation.Start)]), + _sliceSyntax); + _withAnotherCommand = SpecificationRenderer.Unrenderable( + 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")]), _sliceSyntax); + } + + // 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"); + + // 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); +} 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."); } diff --git a/Stage.slnx b/Stage.slnx index 0f39a7e..0661f0a 100644 --- a/Stage.slnx +++ b/Stage.slnx @@ -2,6 +2,7 @@ +