diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9bb3e62..d859980 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,7 +6,9 @@
+
+
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.");
}