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."); }