From b8e65b5728d4b47e9732e538e33c0d1c6cae3ac6 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:38:55 +0200 Subject: [PATCH 1/4] Say what a scenario a slice is specified by holds Screenplay has a specification construct - given, when, then - and nothing in the model to fill it from, so the generator has never written one. This introduces the shape a scenario is recovered into and the names the source of one is recognized by, ahead of the reading and the writing that follow. A given, a when and a then are the same shape - a name and a list of values - so one model covers all three and the step it sits in says which it is. A rejection carries the reason the source names for it, and an empty one where the source names none: the scenario is named after the words the source uses for it, so what it was about is already said there and inventing a sentence to repeat it would describe an application nobody wrote. Co-Authored-By: Claude Opus 5 --- .../Screenplay/Analysis/WellKnownTypeNames.cs | 15 +++++++++++ Source/DotNET/Screenplay/Model/SliceModel.cs | 10 +++++++ .../Screenplay/Model/SpecificationModel.cs | 25 ++++++++++++++++++ .../Model/SpecificationStateKind.cs | 25 ++++++++++++++++++ .../Model/SpecificationStateModel.cs | 16 ++++++++++++ .../Screenplay/ScreenplayDiagnosticCodes.cs | 26 +++++++++++++++++++ 6 files changed, 117 insertions(+) create mode 100644 Source/DotNET/Screenplay/Model/SpecificationModel.cs create mode 100644 Source/DotNET/Screenplay/Model/SpecificationStateKind.cs create mode 100644 Source/DotNET/Screenplay/Model/SpecificationStateModel.cs diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs index 62fc50fb4..4bd288e83 100644 --- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs +++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs @@ -123,6 +123,21 @@ public static class WellKnownTypeNames /// The everything a query is performed with, which the host fills in. public const string QueryContext = "Cratis.Arc.Queries.QueryContext"; + /// The sequence a specification appends the events it starts from to. + public const string EventSequence = "Cratis.Chronicle.EventSequences.IEventSequence"; + + /// The scenario a specification issues a command through in process. + public const string CommandScenario = "Cratis.Arc.Testing.Commands.CommandScenario`1"; + + /// The builder a specification states the state of one event source with. + public const string CommandScenarioSourceGivenBuilder = "Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioSourceGivenBuilder`1"; + + /// The extensions a specification issues a command through over HTTP. + public const string HttpClientExtensions = "Cratis.Chronicle.XUnit.Integration.HttpClientExtensions"; + + /// The attribute marking a method as one assertion of a specification. + public const string FactAttribute = "Xunit.FactAttribute"; + /// The page of a result a query is performed for, which the host fills in from the request. public const string Paging = "Cratis.Arc.Queries.Paging"; diff --git a/Source/DotNET/Screenplay/Model/SliceModel.cs b/Source/DotNET/Screenplay/Model/SliceModel.cs index 9aa5a0838..aeb944390 100644 --- a/Source/DotNET/Screenplay/Model/SliceModel.cs +++ b/Source/DotNET/Screenplay/Model/SliceModel.cs @@ -37,6 +37,16 @@ public record SliceModel( /// public IEnumerable Screens { get; init; } = []; + /// + /// Gets the scenarios the slice is specified by. + /// + /// + /// A scenario is recovered from a namespace beneath the slice's own rather than from the slice's, so it arrives + /// once every namespace has been read and which of them declare a slice is known - which is after the slice + /// itself exists. + /// + public IEnumerable Specifications { get; init; } = []; + /// /// Creates a slice that declares nothing, for use as a starting point. /// diff --git a/Source/DotNET/Screenplay/Model/SpecificationModel.cs b/Source/DotNET/Screenplay/Model/SpecificationModel.cs new file mode 100644 index 000000000..fa68980f1 --- /dev/null +++ b/Source/DotNET/Screenplay/Model/SpecificationModel.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Model; + +/// +/// Represents one scenario a slice is specified by - what had happened, the command that was issued and what +/// followed. +/// +/// The name of the specification. +/// What had already happened when the command was issued. +/// The command that was issued. +/// The events and read model states that followed. +/// The rejections that followed, each named by the reason the source gives for it. +/// +/// A rejection the source names no reason for carries an empty one. The scenario is named after the words the +/// source itself uses for it, so what the rejection was about is already said by the name and inventing a sentence +/// to repeat it would be describing an application nobody wrote. +/// +public record SpecificationModel( + string Name, + IEnumerable Given, + SpecificationStateModel When, + IEnumerable Then, + IEnumerable Errors); diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs new file mode 100644 index 000000000..6bf316bd5 --- /dev/null +++ b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Model; + +/// +/// Represents what a state a specification names actually is. +/// +public enum SpecificationStateKind +{ + /// + /// An event, which is what a given and a then name by default. + /// + Event = 0, + + /// + /// A read model, which a given and a then name behind the readmodel keyword. + /// + ReadModel = 1, + + /// + /// A command, which is what the single when of a specification names. + /// + Command = 2 +} diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs new file mode 100644 index 000000000..0bdaf3dec --- /dev/null +++ b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Model; + +/// +/// Represents one artifact a specification names, together with the values it states for it. +/// +/// The name of the event, read model or command being named. +/// What the named artifact is. +/// The values the specification states, in the order the source declares them. +/// +/// A given, a when and a then are the same shape - a name and a list of values - so one model +/// covers all three and the step it belongs to says which of them it is. +/// +public record SpecificationStateModel(string Name, SpecificationStateKind Kind, IEnumerable Values); diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs index c4e01f22f..aec4f8f9e 100644 --- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs @@ -285,4 +285,30 @@ public static class ScreenplayDiagnosticCodes /// the binding the document is missing, and what turns it into one the moment a reference can carry the slice. /// public const string CrossSliceQueryBinding = "SP0036"; + + /// + /// A scenario a slice is specified by states a step that could not be read, so the whole scenario was left out. + /// + /// + /// A specification is one concrete example, and an example missing the command it issues, the state it started + /// from or the outcome it expects is not that example - it is a different one nobody wrote. So unlike a mapping, + /// which stands on its own and can be left out while the rest of the block still says something true, a step that + /// cannot be read takes the scenario with it. What made it unreadable is said, because the difference between a + /// scenario resting on a value computed at run time and one resting on a helper the reader could inline is the + /// difference between a gap that will always be there and one an afternoon closes. + /// + public const string UnreadableSpecification = "SP0037"; + + /// + /// A value a scenario states is code rather than a constant, so the scenario states everything but that value. + /// + /// + /// A scenario is written in the host language, where the identity two steps agree on is routinely a fresh + /// identifier held in a field rather than something written down twice. Screenplay states values and has no way + /// to name one, so such a value is left out and the rest of the scenario stands: which events had happened, which + /// command was issued and what followed are all still exactly what the source says. This is reported per value + /// rather than per scenario for the same reason a produces mapping is - a reader counting what a scenario states + /// against what the source states otherwise has no way of knowing which of the two it is looking at. + /// + public const string UnreadableSpecificationValue = "SP0038"; } From 41e68a810155de0edc1719d181163c001132541b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:39:15 +0200 Subject: [PATCH 2/4] Recover the scenarios a slice is specified by from its own source A Cratis application carries its integration specifications beside the slice they are about, in a shape rigid enough to read: what had happened is stated against a scenario or appended to the event log, one command is issued, and each assertion says one thing about what followed. That is the specification construct exactly, so the scenarios were there all along and the document simply never carried them. Both shapes Arc documents are read - the pipeline driven in process and a running host driven over HTTP - matched on the names of the testing types alone, so neither testing package has to be referenced. A specification about a collaborator is passed over rather than reported, because what is read is decided by what a specification touches rather than by where it sits. Values are recovered the way a produces mapping is, one source shorter: a scenario refers to nothing outside itself, so a constant is the only source of a value there is. A value that is code is left out and the rest of the scenario stands. A step that is code, or one that only happens under a condition, takes the scenario with it - an example missing the state it started from is a different example from the one the source states, and stating it would be the one failure mode a reader cannot catch. Co-Authored-By: Claude Opus 5 --- .../Analysis/ApplicationModelAnalyzer.cs | 22 +++ .../Analysis/Specifications/CallArguments.cs | 96 +++++++++++ .../Specifications/SpecificationAssertions.cs | 95 +++++++++++ .../Specifications/SpecificationCalls.cs | 129 +++++++++++++++ .../Specifications/SpecificationCatalog.cs | 88 ++++++++++ .../Specifications/SpecificationDraft.cs | 49 ++++++ .../Specifications/SpecificationMembers.cs | 114 +++++++++++++ .../SpecificationOutcomeReader.cs | 151 +++++++++++++++++ .../Specifications/SpecificationPlacement.cs | 79 +++++++++ .../Specifications/SpecificationReader.cs | 133 +++++++++++++++ .../Specifications/SpecificationStepReader.cs | 154 ++++++++++++++++++ .../Specifications/SpecificationValues.cs | 135 +++++++++++++++ .../Analysis/Specifications/Step.cs | 20 +++ .../Analysis/Specifications/StepsTaken.cs | 64 ++++++++ 14 files changed, 1329 insertions(+) create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/Step.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs diff --git a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs index 5511d39eb..b11136289 100644 --- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs +++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs @@ -5,6 +5,7 @@ using Cratis.Arc.Screenplay.Analysis.Policies; using Cratis.Arc.Screenplay.Analysis.Screens; using Cratis.Arc.Screenplay.Analysis.Slices; +using Cratis.Arc.Screenplay.Analysis.Specifications; using Cratis.Arc.Screenplay.Model; using Microsoft.CodeAnalysis; @@ -56,6 +57,8 @@ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptio .OfType() .ToList(); + slices = Specified(compilation, catalog, slices, diagnostics); + ConceptValidations.Link(catalog, readers); readers.AggregateRoots.Report(diagnostics); elsewhere.Report(diagnostics); @@ -91,6 +94,25 @@ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptio diagnostics.All); } + /// + /// Puts every scenario the application specifies its slices by under the slice it belongs to. + /// + /// The compilation being analyzed. + /// The catalogue of everything the compilation declares. + /// The slices recovered so far. + /// The diagnostics to report to. + /// The slices, each carrying the scenarios it is specified by. + static List Specified( + Compilation compilation, + ArtifactCatalog catalog, + List slices, + ScreenplayDiagnostics diagnostics) + { + var specifications = SpecificationCatalog.Read(compilation, catalog, slices.Select(_ => _.Namespace), diagnostics); + + return [.. slices.Select(slice => slice with { Specifications = [.. specifications.For(slice.Namespace)] })]; + } + /// /// Gets everything within a slice that requires something of the caller. /// diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs new file mode 100644 index 000000000..f4bccd55f --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs @@ -0,0 +1,96 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Gets the expressions a call gives to one of its parameters. +/// +/// +/// What a specification states sits in one argument of a call, and which one it is has to be resolved by name rather +/// than by position - the calls carrying it declare everything after it as optional, take it through a parameter +/// array, or are extension methods whose first parameter is the receiver. A parameter array and a parameter taking a +/// collection are both flattened, so that stating three events in one call and stating them in three reads the same +/// way. +/// +public static class CallArguments +{ + /// + /// Gets the expressions a call gives to a parameter. + /// + /// The call to read. + /// The method being called. + /// The name of the parameter to read. + /// The expressions, in the order the call declares them. + public static IEnumerable For(InvocationExpressionSyntax invocation, IMethodSymbol method, string parameter) + { + var arguments = invocation.ArgumentList.Arguments; + var given = new List(); + + for (var index = 0; index < arguments.Count; index++) + { + if (string.Equals(NameOf(arguments[index], method, index), parameter, StringComparison.Ordinal)) + { + given.AddRange(Flatten(arguments[index].Expression)); + } + } + + return given; + } + + /// + /// Gets the name of the parameter an argument fills in. + /// + /// The argument to name. + /// The method being called. + /// The position of the argument. + /// The parameter name, or when it cannot be resolved. + static string? NameOf(ArgumentSyntax argument, IMethodSymbol method, int index) + { + if (argument.NameColon is { } named) + { + return named.Name.Identifier.ValueText; + } + + if (index < method.Parameters.Length) + { + return method.Parameters[index].Name; + } + + return method.Parameters is [.., { IsParams: true } last] ? last.Name : null; + } + + /// + /// Opens up an expression that holds several values into the values it holds. + /// + /// The expression to open up. + /// The values, or the expression itself when it holds one. + static IEnumerable Flatten(ExpressionSyntax expression) => expression switch + { + CollectionExpressionSyntax collection => collection.Elements.Select(ValueOf), + ImplicitArrayCreationExpressionSyntax implicitly => implicitly.Initializer.Expressions, + ArrayCreationExpressionSyntax array when array.Initializer is { } initializer => initializer.Expressions, + _ => [expression] + }; + + /// + /// Gets the expression one element of a collection stands for. + /// + /// The element to read. + /// The expression. + /// + /// An element spreading another collection into this one stands for however many values that collection holds, + /// which is not something a source text says. The expression being spread is returned as it is, so that whoever + /// asked for the values finds one it cannot read rather than a list quietly missing them. + /// + static ExpressionSyntax ValueOf(CollectionElementSyntax element) => element switch + { + ExpressionElementSyntax value => value.Expression, + SpreadElementSyntax spread => spread.Expression, + _ => SyntaxFactory.IdentifierName(element.ToString()) + }; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs new file mode 100644 index 000000000..16aaba1e7 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Recognizes what an assertion of a specification says the outcome was. +/// +/// +/// Assertions are matched on the name of the helper alone. The same helper is declared several times over across the +/// testing packages - once for the result of a command, once for the result of an append, once for the scenario +/// itself - and a specification calls whichever of them its own shape reaches, so matching the declaring type would +/// recognize the same sentence in one specification and not in the next. +/// +/// Only the two outcomes Screenplay can hold are recognized: an event was appended, and the command was rejected. +/// Everything else a specification asserts - how far the sequence got, that no exception was thrown, that the result +/// was valid - says nothing the language has a place for and is passed over rather than reported, in the same way a +/// unit level specification is. +/// +/// +public static class SpecificationAssertions +{ + /// The assertion naming an event the command appended. + public const string AppendedEventAssertion = "ShouldHaveAppendedEvent"; + + /// The assertion saying a value is false, which is a rejection when the value is the success of a result. + public const string FalseAssertion = "ShouldBeFalse"; + + /// The value of a result saying whether the command was carried out. + public const string SuccessProperty = "IsSuccess"; + + /// The assertions saying the command was rejected, without naming why. + public static readonly string[] Rejections = + [ + "ShouldHaveExceptions", + "ShouldHaveValidationErrors", + "ShouldNotBeAuthorized", + "ShouldNotBeSuccessful" + ]; + + /// The assertions saying the command was rejected and naming the reason. + public static readonly string[] NamedRejections = + [ + "ShouldHaveConstraintViolation", + "ShouldHaveConstraintViolationFor", + "ShouldHaveValidationErrorFor" + ]; + + /// + /// Gets the event an assertion says was appended. + /// + /// The method being called. + /// The event type, or when the assertion is about something else. + /// + /// The event is the last type argument rather than the only one, because the helper taking the scenario also + /// takes the command it is a scenario for, which has to be named ahead of it. + /// + public static ITypeSymbol? AppendedEventOf(IMethodSymbol method) => + string.Equals(method.Name, AppendedEventAssertion, StringComparison.Ordinal) + ? method.TypeArguments.LastOrDefault() + : null; + + /// + /// Determines whether an assertion says the command was rejected. + /// + /// The assertion to read. + /// The method being called. + /// True when the assertion is a rejection. + public static bool IsRejection(InvocationExpressionSyntax invocation, IMethodSymbol method) => + Array.Exists(Rejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal)) || + IsNamedRejection(method) || + IsUnsuccessful(invocation, method); + + /// + /// Determines whether an assertion names the reason the command was rejected. + /// + /// The method being called. + /// True when the assertion names a reason. + public static bool IsNamedRejection(IMethodSymbol method) => + Array.Exists(NamedRejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal)); + + /// + /// Determines whether an assertion says the result of the command was not a success. + /// + /// The assertion to read. + /// The method being called. + /// True when the assertion is about the success of a result. + static bool IsUnsuccessful(InvocationExpressionSyntax invocation, IMethodSymbol method) => + string.Equals(method.Name, FalseAssertion, StringComparison.Ordinal) && + invocation.Expression is MemberAccessExpressionSyntax { Expression: MemberAccessExpressionSyntax subject } && + string.Equals(subject.Name.Identifier.ValueText, SuccessProperty, StringComparison.Ordinal); +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs new file mode 100644 index 000000000..60cad5db5 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs @@ -0,0 +1,129 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Recognizes the calls a Chronicle integration specification is written with. +/// +/// +/// Two ways of writing one are recognized, because Arc documents both. A specification driving the command pipeline +/// in process states what had happened through the scenario it holds and issues the command through the same +/// scenario; one driving a running host states it against the event log and issues the command over HTTP. Everything +/// is matched on the fully qualified metadata name of the type declaring the method, so neither testing package has +/// to be referenced for either to be read. +/// +public static class SpecificationCalls +{ + /// The method appending one event to a sequence. + public const string AppendMethod = "Append"; + + /// The method appending several events to a sequence as one transaction. + public const string AppendManyMethod = "AppendMany"; + + /// The method stating the events that had happened for one event source. + public const string EventsMethod = "Events"; + + /// The method pinning the read model one event source resolves to. + public const string ReadModelMethod = "ReadModel"; + + /// The method issuing a command through the in-process pipeline. + public const string ExecuteMethod = "Execute"; + + /// The method taking a command as far as the pipeline validates it. + public const string ValidateMethod = "Validate"; + + /// The method issuing a command over HTTP. + public const string ExecuteCommandMethod = "ExecuteCommand"; + + /// The parameter carrying the single event an append is given. + public const string EventParameter = "event"; + + /// The parameter carrying the events an append or a scenario is given. + public const string EventsParameter = "events"; + + /// The parameter carrying the read model a scenario is pinned with. + public const string ReadModelParameter = "readModel"; + + /// The parameter carrying the command issued over HTTP. + public const string CommandParameter = "command"; + + /// + /// Determines whether a call states the events a specification starts from. + /// + /// The method being called. + /// True when the call states events. + public static bool IsGivenEvents(IMethodSymbol method) => + (IsOn(method, WellKnownTypeNames.EventSequence) && (Named(method, AppendMethod) || Named(method, AppendManyMethod))) || + (IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, EventsMethod)); + + /// + /// Determines whether a call pins the read model a specification starts from. + /// + /// The method being called. + /// True when the call pins a read model. + public static bool IsGivenReadModel(IMethodSymbol method) => + IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, ReadModelMethod); + + /// + /// Determines whether a call issues the command a specification is about. + /// + /// The method being called. + /// True when the call issues a command. + public static bool IsExecution(IMethodSymbol method) => + (IsOn(method, WellKnownTypeNames.CommandScenario) && (Named(method, ExecuteMethod) || Named(method, ValidateMethod))) || + (IsOn(method, WellKnownTypeNames.HttpClientExtensions) && Named(method, ExecuteCommandMethod)); + + /// + /// Gets the name of the parameter carrying what a recognized call is given. + /// + /// The method being called. + /// The parameter name, or when the call carries nothing this reads. + public static string? PayloadParameterOf(IMethodSymbol method) + { + if (IsGivenReadModel(method)) + { + return ReadModelParameter; + } + + if (IsExecution(method)) + { + return Named(method, ExecuteCommandMethod) ? CommandParameter : method.Parameters.FirstOrDefault()?.Name; + } + + if (!IsGivenEvents(method)) + { + return null; + } + + return Named(method, AppendMethod) ? EventParameter : EventsParameter; + } + + /// + /// Determines whether a method carries a name. + /// + /// The method to check. + /// The name to match. + /// True when the names are the same. + static bool Named(IMethodSymbol method, string name) => string.Equals(method.Name, name, StringComparison.Ordinal); + + /// + /// Determines whether a method belongs to a named type, following the type it was reduced from. + /// + /// The method to check. + /// The fully qualified metadata name of the declaring type. + /// True when the method belongs to that type. + /// + /// A specialization of an interface inherits its members without redeclaring them, so the interface a member is + /// declared on is matched as well as the one it is reached through - appending to the event log is appending to a + /// sequence, whichever of the two the call site names. + /// + static bool IsOn(IMethodSymbol method, string fullMetadataName) + { + var declaring = (method.ReducedFrom ?? method).ContainingType; + + return declaring.Is(fullMetadataName) || declaring.FindInterface(fullMetadataName) is not null; + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs new file mode 100644 index 000000000..64567b0d4 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs @@ -0,0 +1,88 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Holds every scenario the application specifies its slices by, arranged under the slice each belongs to. +/// +/// +/// Specifications are read once the slices are in, because which slice a scenario belongs to is answered by which +/// namespaces turned out to declare one. Reading them alongside would mean deciding that against namespaces still +/// being read, and a scenario would land under a different slice depending on the order the compilation was walked. +/// +public class SpecificationCatalog +{ + readonly Dictionary> _bySlice; + + SpecificationCatalog(Dictionary> bySlice) => _bySlice = bySlice; + + /// + /// Reads every specification the compilation declares. + /// + /// The compilation being analyzed. + /// The catalogue of everything the compilation declares. + /// The namespaces a slice was recovered from. + /// The anything unreadable is reported to. + /// The . + public static SpecificationCatalog Read( + Compilation compilation, + ArtifactCatalog catalog, + IEnumerable slices, + ScreenplayDiagnostics diagnostics) + { + var reader = new SpecificationReader(compilation, diagnostics); + var placement = new SpecificationPlacement(slices); + var bySlice = new Dictionary>(StringComparer.Ordinal); + + foreach (var type in catalog.Types.Where(reader.IsSpecification)) + { + if (placement.SliceOf(type) is not { } slice) + { + diagnostics.Warning( + ScreenplayDiagnosticCodes.UnreadableSpecification, + $"The scenario '{type.Name}' was left out because no namespace above it declares a slice for it to specify", + type.ToDisplayString()); + + continue; + } + + if (reader.Read(type, SpecificationPlacement.NameOf(type, slice)) is { } specification) + { + Add(bySlice, slice, specification); + } + } + + return new(bySlice); + } + + /// + /// Gets the specifications belonging to a slice. + /// + /// The namespace of the slice. + /// The specifications, ordered by name. + public IEnumerable For(string @namespace) => + _bySlice.TryGetValue(@namespace, out var specifications) + ? specifications.OrderBy(_ => _.Name, StringComparer.Ordinal) + : []; + + /// + /// Adds a specification under the slice it belongs to. + /// + /// The specifications collected so far. + /// The namespace of the slice. + /// The specification to add. + static void Add(Dictionary> bySlice, string slice, SpecificationModel specification) + { + if (!bySlice.TryGetValue(slice, out var specifications)) + { + specifications = []; + bySlice[slice] = specifications; + } + + specifications.Add(specification); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs new file mode 100644 index 000000000..c868c0bc1 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Collects a specification while the type declaring it is being read. +/// +/// +/// A scenario is one example, so the first step of it that cannot be read decides the outcome for all of them. The +/// reason is kept rather than the fact, because what made a scenario unreadable is the only part of it worth +/// reporting - and the first reason is kept rather than the last, since every reason after it is read from a +/// scenario already known to be incomplete. +/// +public class SpecificationDraft +{ + /// + /// Gets what had already happened when the command was issued. + /// + public IList Given { get; } = []; + + /// + /// Gets the events and read model states that followed. + /// + public IList Then { get; } = []; + + /// + /// Gets the rejections that followed, each named by the reason the source gives for it. + /// + public IList Errors { get; } = []; + + /// + /// Gets or sets the command that was issued. + /// + public SpecificationStateModel? When { get; set; } + + /// + /// Gets why the scenario could not be read, or while all of it still can be. + /// + public string? Unreadable { get; private set; } + + /// + /// Records why the scenario cannot be read. + /// + /// What made it unreadable. + public void CannotRead(string reason) => Unreadable ??= reason; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs new file mode 100644 index 000000000..56f761fc4 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs @@ -0,0 +1,114 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Finds the parts of a specification within the chain of types it is written across. +/// +/// +/// A specification inherits the world it starts from. Every type in the chain may set part of it up, and the +/// specification framework calls each of those in turn from the base down, so the chain is walked in that same order +/// and what it states comes out in the order it really happens in. +/// +/// Where the steps are written differs between the two shapes Arc documents. A specification driving the pipeline in +/// process writes them on itself; one driving a running host writes them on a nested type the fixture is handed to, +/// and keeps only its assertions outside. Which of the two a specification is follows from where the steps are, not +/// from what it derives from, so a chain no package declares reads exactly the same way. +/// +/// +public static class SpecificationMembers +{ + /// The method setting up the world a specification starts from. + public const string EstablishMethod = "Establish"; + + /// The method performing the single action a specification is about. + public const string BecauseMethod = "Because"; + + /// The nested type the steps of a specification against a running host are written on. + public const string ContextType = "context"; + + /// + /// Gets the type the steps of a specification are written on. + /// + /// The type declaring the specification. + /// The nested context when the specification has one, otherwise the type itself. + public static INamedTypeSymbol StepsOf(INamedTypeSymbol type) => + type.GetTypeMembers(ContextType).FirstOrDefault(_ => Declares(_, BecauseMethod) || Declares(_, EstablishMethod)) ?? type; + + /// + /// Gets the chain of types a type is written across, from the base down. + /// + /// The type to walk. + /// The chain, base first. + public static IEnumerable ChainOf(INamedTypeSymbol type) + { + var chain = new List(); + for (var current = type; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) + { + chain.Insert(0, current); + } + + return chain; + } + + /// + /// Gets every declaration of a method in a chain of types. + /// + /// The type to walk from. + /// The name of the method. + /// The methods, from the base down. + public static IEnumerable MethodsIn(INamedTypeSymbol type, string name) => + ChainOf(type).SelectMany(_ => _.GetMembers(name)).OfType().Where(_ => _.MethodKind == MethodKind.Ordinary); + + /// + /// Gets every assertion a specification makes. + /// + /// The type declaring the specification. + /// The assertions, ordered by name so that the same source always reads the same way. + public static IEnumerable AssertionsIn(INamedTypeSymbol type) => + ChainOf(type) + .SelectMany(_ => _.GetMembers()) + .OfType() + .Where(_ => _.HasAttribute(WellKnownTypeNames.FactAttribute)) + .OrderBy(_ => _.Name, StringComparer.Ordinal) + .ThenBy(_ => _.ToDisplayString(), StringComparer.Ordinal); + + /// + /// Determines whether a type holds a scenario the command pipeline is driven through. + /// + /// The type to check. + /// True when the type or one of its bases holds a scenario. + /// + /// Holding one is what makes a specification an integration specification of the slice rather than a unit level + /// one about a collaborator, and it holds whether or not the command is issued somewhere this can read. That is + /// exactly why it is asked: a specification that holds a scenario and issues its command through a helper is one + /// this cannot read, and saying so is the difference between a known gap and a silent one. + /// + public static bool HoldsAScenario(INamedTypeSymbol type) => + ChainOf(type) + .SelectMany(_ => _.GetMembers()) + .Any(member => TypeOf(member).Is(WellKnownTypeNames.CommandScenario)); + + /// + /// Determines whether a type declares a method itself. + /// + /// The type to check. + /// The name of the method. + /// True when the type declares it. + static bool Declares(INamedTypeSymbol type, string name) => type.GetMembers(name).OfType().Any(); + + /// + /// Gets the type a member holds a value of. + /// + /// The member to read. + /// The type, or when the member holds no value. + static ITypeSymbol? TypeOf(ISymbol member) => member switch + { + IFieldSymbol field => field.Type, + IPropertySymbol property => property.Type, + _ => null + }; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs new file mode 100644 index 000000000..a41fcc5da --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs @@ -0,0 +1,151 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Analysis.Events; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads what a specification says followed from the command it issued. +/// +/// The compilation being analyzed. +/// The anything unreadable is reported to. +/// +/// Each assertion is one sentence about the outcome, and several of them routinely say the same sentence about a +/// different part of the same event - once for each value it carries. Screenplay says an event followed once, so a +/// sentence already said is passed over rather than repeated. +/// +public class SpecificationOutcomeReader(Compilation compilation, ScreenplayDiagnostics diagnostics) +{ + /// + /// Reads what a specification says followed. + /// + /// The type declaring the specification. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives, for use in diagnostics. + public void Read(INamedTypeSymbol type, SpecificationDraft draft, string name, string location) + { + foreach (var assertion in SpecificationMembers.AssertionsIn(type)) + { + foreach (var body in HandlerBodies.Of(assertion)) + { + ReadBody(body, compilation.GetSemanticModel(body.SyntaxTree), draft, name, location); + } + } + } + + /// + /// Adds an event a specification says followed, or records that it cannot be read. + /// + /// The type the assertion names. + /// The scenario collected so far. + static void AddEvent(ITypeSymbol appended, SpecificationDraft draft) + { + if (!EventReader.IsEvent(appended)) + { + draft.CannotRead($"it expects '{appended.Name}' to follow, which is not declared as an event"); + return; + } + + if (!draft.Then.Any(_ => string.Equals(_.Name, appended.Name, StringComparison.Ordinal))) + { + draft.Then.Add(new(appended.Name, SpecificationStateKind.Event, [])); + } + } + + /// + /// Reads what one assertion says followed. + /// + /// The body of the assertion. + /// The semantic model of the tree the body lives in. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives. + void ReadBody(SyntaxNode body, SemanticModel semanticModel, SpecificationDraft draft, string name, string location) + { + foreach (var invocation in body.DescendantNodesAndSelf().OfType()) + { + if (semanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method) + { + continue; + } + + var appended = SpecificationAssertions.AppendedEventOf(method); + var rejection = SpecificationAssertions.IsRejection(invocation, method); + if (appended is null && !rejection) + { + continue; + } + + if (!StepsTaken.Always(invocation, body)) + { + draft.CannotRead("what it expects to follow is only expected under a condition, and a scenario says what followed"); + return; + } + + if (appended is not null) + { + AddEvent(appended, draft); + continue; + } + + AddError(invocation, method, semanticModel, draft, name, location); + } + } + + /// + /// Adds a rejection a specification says followed, named by the reason the source gives for it. + /// + /// The assertion to read. + /// The method being called. + /// The semantic model of the tree the assertion lives in. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives. + void AddError( + InvocationExpressionSyntax invocation, + IMethodSymbol method, + SemanticModel semanticModel, + SpecificationDraft draft, + string name, + string location) + { + var reason = SpecificationAssertions.IsNamedRejection(method) + ? ReasonOf(invocation, semanticModel, name, location) + : string.Empty; + + if (!draft.Errors.Contains(reason, StringComparer.Ordinal)) + { + draft.Errors.Add(reason); + } + } + + /// + /// Gets the reason an assertion names for a rejection. + /// + /// The assertion to read. + /// The semantic model of the tree the assertion lives in. + /// The name of the specification. + /// Where the specification lives. + /// The reason, empty when the source names one this cannot read. + string ReasonOf(InvocationExpressionSyntax invocation, SemanticModel semanticModel, string name, string location) + { + var named = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; + if (named is not null && semanticModel.GetConstantValue(named) is { HasValue: true, Value: { } value }) + { + return value.ToString() ?? string.Empty; + } + + diagnostics.Information( + ScreenplayDiagnosticCodes.UnreadableSpecificationValue, + $"The reason '{name}' gives for a rejection is code rather than a constant, so the rejection is stated without one", + location); + + return string.Empty; + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs new file mode 100644 index 000000000..8663601f3 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Decides which slice a specification belongs to and what it is called there. +/// +/// The namespaces a slice was recovered from. +/// +/// A specification sits in a folder beneath the slice it is about, which is a namespace beneath the slice's own. The +/// slice it belongs to is therefore the nearest namespace above it that declares one - nearest rather than any, +/// because a slice within a slice is exactly what a feature holding both a behavior and the behaviors beneath it +/// looks like. +/// +/// The name is every word between the slice and the specification, in the order the source wrote them. That is what +/// the convention was built to read as - a folder saying when something happens and a file saying what else was true +/// at the time - so keeping all of it is what makes the specification say in the document what it says in the source. +/// +/// +public class SpecificationPlacement(IEnumerable slices) +{ + /// The namespace segment holding the world several specifications share. + public const string SharedContextSegment = "given"; + + readonly HashSet _slices = [.. slices]; + + /// + /// Gets the name a specification is declared under. + /// + /// The type declaring the specification. + /// The namespace of the slice it belongs to. + /// The name, as the words the source wrote joined together. + public static string NameOf(INamedTypeSymbol type, string slice) + { + var below = Segments(type)[SegmentsIn(slice)..]; + + return string.Join( + '_', + below.Where(_ => !string.Equals(_, SharedContextSegment, StringComparison.Ordinal)).Append(type.Name)); + } + + /// + /// Gets the namespace of the slice a specification belongs to. + /// + /// The type declaring the specification. + /// The namespace, or when no slice above it declares anything. + public string? SliceOf(INamedTypeSymbol type) + { + var segments = Segments(type); + + for (var depth = segments.Length - 1; depth > 0; depth--) + { + var candidate = string.Join('.', segments[..depth]); + if (_slices.Contains(candidate)) + { + return candidate; + } + } + + return null; + } + + /// + /// Gets the segments of the namespace a type lives in, with the type left out. + /// + /// The type to read. + /// The segments. + static string[] Segments(INamedTypeSymbol type) => type.Namespace().Split('.', StringSplitOptions.RemoveEmptyEntries); + + /// + /// Counts the segments of a namespace. + /// + /// The namespace to count. + /// The number of segments. + static int SegmentsIn(string @namespace) => @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length; +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs new file mode 100644 index 000000000..efb5b396d --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs @@ -0,0 +1,133 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads the scenario one type specifies a slice by. +/// +/// The compilation being analyzed. +/// The anything unreadable is reported to. +/// +/// Only a specification driving a command through the real pipeline is read. A unit level one stands a collaborator +/// up behind a substitute and says what that collaborator was asked to do, which is a statement about the inside of +/// the slice rather than about its behavior, and Screenplay has nowhere to put it. Whether a specification is one or +/// the other is decided by what it touches: holding a scenario the pipeline runs in, or reaching the event log, is +/// what an integration specification does and nothing else does. +/// +public class SpecificationReader(Compilation compilation, ScreenplayDiagnostics diagnostics) +{ + /// + /// Determines whether a type specifies a slice by driving a command through the pipeline. + /// + /// The type to check. + /// True when the type is a specification of the kind this reads. + public bool IsSpecification(INamedTypeSymbol type) + { + if (type is not { TypeKind: TypeKind.Class, IsAbstract: false, ContainingType: null }) + { + return false; + } + + var steps = SpecificationMembers.StepsOf(type); + + return SpecificationMembers.MethodsIn(steps, SpecificationMembers.BecauseMethod).Any() && + (SpecificationMembers.HoldsAScenario(steps) || DrivesASlice(steps)); + } + + /// + /// Reads the scenario a type specifies a slice by. + /// + /// The type declaring the specification. + /// The name the specification is declared under. + /// The , or when the scenario cannot be read. + /// + /// What was left out of a scenario is held back until the scenario is known to survive. Saying that a value was + /// left out of a scenario that was itself left out describes a document that says something it does not say, and + /// there are far more values than scenarios - so the noise would be the loudest thing in the report. + /// + public SpecificationModel? Read(INamedTypeSymbol type, string name) + { + var location = type.ToDisplayString(); + var steps = SpecificationMembers.StepsOf(type); + var draft = new SpecificationDraft(); + var stated = new ScreenplayDiagnostics(); + + var reader = new SpecificationStepReader(compilation, new(stated)); + reader.ReadGiven(steps, draft, name, location); + reader.ReadWhen(steps, draft, name, location); + new SpecificationOutcomeReader(compilation, stated).Read(type, draft, name, location); + + if (draft.When is null) + { + draft.CannotRead("the command it issues is put together somewhere this cannot read"); + } + else if (draft.Then.Count == 0 && draft.Errors.Count == 0) + { + draft.CannotRead("it expects no event and no rejection, and those are the outcomes the language holds"); + } + + if (draft.Unreadable is not null) + { + diagnostics.Warning( + ScreenplayDiagnosticCodes.UnreadableSpecification, + $"The scenario '{name}' was left out because {draft.Unreadable}", + location); + + return null; + } + + diagnostics.AddRange(stated.All); + + return new(name, [.. draft.Given], draft.When, [.. draft.Then], [.. draft.Errors]); + } + + /// + /// Determines whether the steps of a specification drive a slice rather than exercise a part of one. + /// + /// The type the steps are written on. + /// True when the specification issues a command, or says what the event store already held. + /// + /// A specification against a running host holds no scenario of its own - the host is the scenario - so what makes + /// it one is that it hands a command to the host, or says what the event log already held before doing so. + /// + /// Where the two are looked for differs, and deliberately. Issuing a command is what a slice does, wherever it is + /// written. Appending to a sequence is only the world a scenario starts in when it happens before the action, and + /// a specification appending as its action is one about the event store itself - a constraint holding, an append + /// being rejected - which is a part of a slice rather than the behavior of one. + /// + /// + bool DrivesASlice(INamedTypeSymbol steps) => + Calls(steps, SpecificationMembers.EstablishMethod) + .Any(_ => SpecificationCalls.IsGivenEvents(_) || SpecificationCalls.IsGivenReadModel(_) || SpecificationCalls.IsExecution(_)) || + Calls(steps, SpecificationMembers.BecauseMethod).Any(SpecificationCalls.IsExecution); + + /// + /// Gets every call a method of a chain resolves to. + /// + /// The type the steps are written on. + /// The name of the method to read. + /// The methods called. + IEnumerable Calls(INamedTypeSymbol steps, string method) => + SpecificationMembers.MethodsIn(steps, method).SelectMany(HandlerBodies.Of).SelectMany(CallsIn); + + /// + /// Gets every call a body resolves to. + /// + /// The body to walk. + /// The methods called. + IEnumerable CallsIn(SyntaxNode body) + { + var semanticModel = compilation.GetSemanticModel(body.SyntaxTree); + + return body.DescendantNodesAndSelf() + .OfType() + .Select(invocation => semanticModel.GetSymbolInfo(invocation).Symbol) + .OfType(); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs new file mode 100644 index 000000000..cf516032b --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.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 Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Analysis.Events; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads what a specification starts from and the command it issues, from the bodies stating them. +/// +/// The compilation being analyzed. +/// The reading the values each step states. +public class SpecificationStepReader(Compilation compilation, SpecificationValues values) +{ + /// + /// Reads what a specification had already seen when it issued its command. + /// + /// The type the steps are written on. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives, for use in diagnostics. + public void ReadGiven(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location) + { + foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.EstablishMethod)) + { + if (!SpecificationCalls.IsGivenEvents(method) && !SpecificationCalls.IsGivenReadModel(method)) + { + continue; + } + + if (!always) + { + draft.CannotRead("what it starts from only happens under a condition, and a scenario says what happened"); + return; + } + + var kind = SpecificationCalls.IsGivenReadModel(method) ? SpecificationStateKind.ReadModel : SpecificationStateKind.Event; + + foreach (var stated in CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty)) + { + Add(draft.Given, stated, kind, semanticModel, draft, name, location); + } + } + } + + /// + /// Reads the command a specification issues. + /// + /// The type the steps are written on. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives, for use in diagnostics. + public void ReadWhen(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location) + { + foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.BecauseMethod)) + { + if (!SpecificationCalls.IsExecution(method)) + { + continue; + } + + if (!always) + { + draft.CannotRead("the command it issues is only issued under a condition, and a scenario says what happened"); + return; + } + + if (draft.When is not null) + { + draft.CannotRead("it issues more than one command, and a scenario is about one"); + return; + } + + var stated = CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty).ToList(); + if (stated is not [BaseObjectCreationExpressionSyntax creation] || + semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol command) + { + draft.CannotRead("the command it issues is put together somewhere this cannot read"); + return; + } + + if (!CommandReader.IsCommand(command)) + { + draft.CannotRead($"'{command.Name}' is not a command the document declares"); + return; + } + + draft.When = new(command.Name, SpecificationStateKind.Command, values.Read(creation, semanticModel, command, name, location)); + } + } + + /// + /// Gets every call a body makes that resolves to a method. + /// + /// The body to walk. + /// The semantic model of the tree the body lives in. + /// The calls, in the order the body makes them. + static IEnumerable Calls(SyntaxNode body, SemanticModel semanticModel) => + body.DescendantNodesAndSelf() + .OfType() + .Select(invocation => (invocation, Method: semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol, semanticModel, body)) + .Where(call => call.Method is not null) + .Select(call => new Step(call.invocation, call.Method!, call.semanticModel, StepsTaken.Always(call.invocation, call.body))); + + /// + /// Gets every call a method of a chain makes, with the model the tree it lives in is read through. + /// + /// The type to walk from. + /// The name of the method to read. + /// The calls, from the base down and in the order each body makes them. + IEnumerable CallsIn(INamedTypeSymbol type, string method) => + SpecificationMembers.MethodsIn(type, method) + .SelectMany(HandlerBodies.Of) + .SelectMany(body => Calls(body, compilation.GetSemanticModel(body.SyntaxTree))); + + /// + /// Adds one state a specification starts from, or records that it cannot be read. + /// + /// The states collected so far. + /// The expression stating it. + /// What the state is. + /// The semantic model of the tree the expression lives in. + /// The scenario collected so far. + /// The name of the specification. + /// Where the specification lives. + void Add( + IList states, + ExpressionSyntax stated, + SpecificationStateKind kind, + SemanticModel semanticModel, + SpecificationDraft draft, + string name, + string location) + { + if (stated is not BaseObjectCreationExpressionSyntax creation || + semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol type) + { + draft.CannotRead("what it starts from is put together somewhere this cannot read"); + return; + } + + if (kind == SpecificationStateKind.Event && !EventReader.IsEvent(type)) + { + draft.CannotRead($"it starts from '{type.Name}', which is not declared as an event"); + return; + } + + states.Add(new(type.Name, kind, values.Read(creation, semanticModel, type, name, location))); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs new file mode 100644 index 000000000..18a383d8c --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs @@ -0,0 +1,135 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Commands; +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Reads the values a specification states for one artifact, from the construction stating them. +/// +/// The anything unreadable is reported to. +/// +/// A specification is closed - it refers to nothing outside itself - so a constant is the only source of a value +/// there is. That is the same discipline a produces mapping is read with, one source shorter: a produces mapping may +/// also name the input of the command being handled, and a scenario has no input to name. +/// +public class SpecificationValues(ScreenplayDiagnostics diagnostics) +{ + readonly MappingSourceReader _sources = new(diagnostics); + + /// + /// Reads the values one construction states. + /// + /// The construction to read. + /// The semantic model of the tree the construction lives in. + /// The type being constructed. + /// The name of the specification, for use in diagnostics. + /// Where the specification lives, for use in diagnostics. + /// The values, in the order the source declares them. + public IEnumerable Read( + BaseObjectCreationExpressionSyntax creation, + SemanticModel semanticModel, + ITypeSymbol type, + string specification, + string location) + { + var values = new List(); + var constructor = semanticModel.GetSymbolInfo(creation).Symbol as IMethodSymbol; + + foreach (var (name, expression) in Stated(creation, constructor)) + { + Add(values, PropertyOf(type, name), expression, semanticModel, type, specification, location); + } + + return values; + } + + /// + /// Gets every value a construction states, from its arguments and from its initializer. + /// + /// The construction to read. + /// The constructor being called. + /// The values, each with the name it fills in, in the order the source declares them. + static IEnumerable<(string Name, ExpressionSyntax Expression)> Stated( + BaseObjectCreationExpressionSyntax creation, + IMethodSymbol? constructor) + { + var arguments = creation.ArgumentList?.Arguments ?? []; + for (var index = 0; index < arguments.Count; index++) + { + if (NameOf(arguments[index], constructor, index) is { } name) + { + yield return (name, arguments[index].Expression); + } + } + + foreach (var assignment in creation.Initializer?.Expressions.OfType() ?? []) + { + if (assignment.Left is IdentifierNameSyntax identifier) + { + yield return (identifier.Identifier.ValueText, assignment.Right); + } + } + } + + /// + /// Gets the name of the property an argument fills in. + /// + /// The argument to name. + /// The constructor being called. + /// The position of the argument. + /// The parameter name, or when it cannot be resolved. + static string? NameOf(ArgumentSyntax argument, IMethodSymbol? constructor, int index) + { + if (argument.NameColon is { } named) + { + return named.Name.Identifier.ValueText; + } + + return constructor is not null && index < constructor.Parameters.Length ? constructor.Parameters[index].Name : null; + } + + /// + /// Resolves the declared casing of a property from the name an argument used. + /// + /// The type being constructed. + /// The name to resolve. + /// The property name. + static string PropertyOf(ITypeSymbol type, string name) => + type.DeclaredProperties().FirstOrDefault(_ => string.Equals(_.Name, name, StringComparison.OrdinalIgnoreCase))?.Name ?? name; + + /// + /// Adds a value, reporting one that is code rather than stating it as something it is not. + /// + /// The values collected so far. + /// The property being filled in. + /// The expression filling it in. + /// The semantic model of the tree the expression lives in. + /// The type being constructed. + /// The name of the specification. + /// Where the specification lives. + void Add( + List values, + string property, + ExpressionSyntax expression, + SemanticModel semanticModel, + ITypeSymbol type, + string specification, + string location) + { + if (_sources.Read(expression, semanticModel, type, location) is LiteralSource literal) + { + values.Add(new(property, literal)); + return; + } + + diagnostics.Information( + ScreenplayDiagnosticCodes.UnreadableSpecificationValue, + $"The value '{specification}' states for '{type.Name}.{property}' is code rather than a constant, so the scenario states everything but that value", + location); + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs new file mode 100644 index 000000000..ce9a3b423 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Represents one call a specification makes, with everything needed to read what it says. +/// +/// The call as it is written. +/// The method it resolves to. +/// The semantic model of the tree it lives in. +/// Whether the body it is written in always makes the call, exactly once. +public record Step( + InvocationExpressionSyntax Invocation, + IMethodSymbol Method, + SemanticModel SemanticModel, + bool Always); diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs new file mode 100644 index 000000000..e0c312396 --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cratis.Arc.Screenplay.Analysis.Specifications; + +/// +/// Answers whether a step written in a body is one the body always takes. +/// +/// +/// A scenario says what happened, once, in order. A step written inside a branch happened in some runs of the +/// specification and not in others, and one written inside a loop happened as many times as the loop went round - +/// neither of which the source text says. Reading such a step as if it always happened states a world the +/// application was never specified against, which is worse than saying nothing: it is the one failure mode a reader +/// has no way of catching. +/// +/// Which condition a branch stood on is routinely knowable to the compiler - a virtual property a derived +/// specification overrides with a constant is the usual shape - but knowing it means following values through +/// dispatch rather than reading what was written, which is a different discipline from the one everything else here +/// keeps to. So a conditional step is left unread and said so, and a scenario that has one is left out whole. +/// +/// +public static class StepsTaken +{ + /// + /// Determines whether a body always takes a step, exactly once. + /// + /// The call the step is written as. + /// The body the step is written in. + /// True when nothing between the two makes the step conditional or repeated. + public static bool Always(SyntaxNode call, SyntaxNode body) + { + for (var current = call.Parent; current is not null && current != body; current = current.Parent) + { + if (IsConditional(current)) + { + return false; + } + } + + return true; + } + + /// + /// Determines whether a construct makes what is written inside it conditional, repeated or deferred. + /// + /// The construct to check. + /// True when it does. + static bool IsConditional(SyntaxNode node) => node switch + { + IfStatementSyntax or SwitchStatementSyntax or SwitchExpressionSyntax => true, + ForStatementSyntax or ForEachStatementSyntax or WhileStatementSyntax or DoStatementSyntax => true, + ConditionalExpressionSyntax or ConditionalAccessExpressionSyntax => true, + CatchClauseSyntax or FinallyClauseSyntax => true, + AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax => true, + BinaryExpressionSyntax binary => binary.IsKind(SyntaxKind.LogicalAndExpression) || + binary.IsKind(SyntaxKind.LogicalOrExpression) || + binary.IsKind(SyntaxKind.CoalesceExpression), + _ => false + }; +} From 9d3eceb6a78330d917fe137159e0162895a61029 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:39:34 +0200 Subject: [PATCH 3/4] Write the scenarios a slice is specified by into the document A document stating what a slice does and carrying none of the scenarios proving it loses half the story, so every scenario recovered is written into the slice it belongs to, ordered by name like everything else in a slice. Nothing a step states is left out over its name. Every other block decides what a line is from its first word, so a property named after a directive collides with it; the values of a step are written one level deeper than the step itself and the step takes every line beneath it as a value, whatever its first word says. Co-Authored-By: Claude Opus 5 --- .../Emission/ApplicationSyntaxBuilder.cs | 4 +- .../Emission/Slices/SliceSyntaxBuilder.cs | 7 +- .../SpecificationSyntaxBuilder.cs | 84 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs diff --git a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs index 403ca5534..f8d9986ae 100644 --- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs @@ -12,6 +12,7 @@ using Cratis.Arc.Screenplay.Emission.Reactors; using Cratis.Arc.Screenplay.Emission.Screens; using Cratis.Arc.Screenplay.Emission.Slices; +using Cratis.Arc.Screenplay.Emission.Specifications; using Cratis.Arc.Screenplay.Emission.Types; using Cratis.Arc.Screenplay.Emission.Validation; using Cratis.Arc.Screenplay.Model; @@ -145,7 +146,8 @@ SliceSyntaxBuilder CreateSliceBuilder() => new ConstraintSyntaxBuilder(naming), new ReactorSyntaxBuilder(naming, diagnostics), new ProjectionSyntaxBuilder(naming, diagnostics, _names), - new ScreenSyntaxBuilder(naming, _types)); + new ScreenSyntaxBuilder(naming, _types), + new SpecificationSyntaxBuilder(naming)); /// /// Sanitizes a document level name, falling back when it yields nothing usable. diff --git a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs index e5ab049d2..585d50edc 100644 --- a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs @@ -9,6 +9,7 @@ using Cratis.Arc.Screenplay.Emission.Queries; using Cratis.Arc.Screenplay.Emission.Reactors; using Cratis.Arc.Screenplay.Emission.Screens; +using Cratis.Arc.Screenplay.Emission.Specifications; using Cratis.Arc.Screenplay.Model; using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; @@ -27,6 +28,7 @@ namespace Cratis.Arc.Screenplay.Emission.Slices; /// The for the reactors of the slice. /// The for the projection of the slice. /// The for the screens of the slice. +/// The for the scenarios the slice is specified by. public class SliceSyntaxBuilder( IScreenplayNaming naming, CommandSyntaxBuilder commands, @@ -35,7 +37,8 @@ public class SliceSyntaxBuilder( ConstraintSyntaxBuilder constraints, ReactorSyntaxBuilder reactors, ProjectionSyntaxBuilder projections, - ScreenSyntaxBuilder screens) + ScreenSyntaxBuilder screens, + SpecificationSyntaxBuilder specifications) { /// /// The name given to a slice whose own name yields nothing usable. @@ -73,7 +76,7 @@ .. slice.Constraints .Select(_ => constraints.Build(_, slice.Namespace)) .OrderBy(_ => _.Name, StringComparer.Ordinal) ], - [], + [.. specifications.Build(slice.Specifications)], SourceLocation.Start, naming.ToStringLiteral(slice.Description)); diff --git a/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs new file mode 100644 index 000000000..89b941387 --- /dev/null +++ b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Emission.Expressions; +using Cratis.Arc.Screenplay.Emission.Naming; +using Cratis.Arc.Screenplay.Model; +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; + +namespace Cratis.Arc.Screenplay.Emission.Specifications; + +/// +/// Builds the Screenplay specification blocks of a slice. +/// +/// The used for name conversion. +/// +/// Nothing a step states is ever left out over its name. Every other block decides what a line is from its first +/// word, so a property called after a directive collides with it; the values of a step are written one level deeper +/// than the step itself and the step takes every line beneath it as a value, whatever its first word says. +/// +public class SpecificationSyntaxBuilder(IScreenplayNaming naming) +{ + readonly MappingSourceConverter _sources = new(naming); + + /// + /// Builds the specifications of a slice. + /// + /// The scenarios the slice is specified by. + /// The specifications, ordered by name. + public IEnumerable Build(IEnumerable specifications) => + [ + .. specifications + .Select(Build) + .OrderBy(_ => _.Name, StringComparer.Ordinal) + ]; + + /// + /// Builds one specification. + /// + /// The scenario to build for. + /// The . + SpecificationSyntax Build(SpecificationModel specification) => + new( + naming.ToDeclarationName(specification.Name), + [.. Events(specification.Given)], + new SpecificationCommandSyntax( + naming.ToDeclarationName(specification.When.Name), + [.. Values(specification.When)], + SourceLocation.Start), + [.. Events(specification.Then)], + [.. specification.Errors.Select(_ => new SpecificationErrorSyntax(naming.ToStringLiteral(_) ?? string.Empty, SourceLocation.Start))], + SourceLocation.Start, + [.. ReadModels(specification.Given)], + [.. ReadModels(specification.Then)]); + + /// + /// Builds the states of a step that name an event. + /// + /// The states to build from. + /// The events. + IEnumerable Events(IEnumerable states) => + states + .Where(_ => _.Kind == SpecificationStateKind.Event) + .Select(_ => new SpecificationEventSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start)); + + /// + /// Builds the states of a step that name a read model. + /// + /// The states to build from. + /// The read models. + IEnumerable ReadModels(IEnumerable states) => + states + .Where(_ => _.Kind == SpecificationStateKind.ReadModel) + .Select(_ => new SpecificationReadModelSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start)); + + /// + /// Builds the values a step states. + /// + /// The state to build from. + /// The values. + IEnumerable Values(SpecificationStateModel state) => + state.Values.Select(_ => new PropertyMappingSyntax(naming.ToPropertyName(_.Property), _sources.Convert(_.Source), SourceLocation.Start)); +} From 8845b4c0f7eb6756459156dda5703f6f4e71ada7 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:39:55 +0200 Subject: [PATCH 4/4] Specify the scenarios a slice is specified by, end to end Both shapes Arc documents are specified against - the pipeline driven in process and a running host driven over HTTP - along with a rejection named and unnamed, a value that is code, and each of the three ways a scenario is left out whole. The testing surface is declared as source rather than referenced, which asks the recognition the only question worth asking of it: whether the names alone are enough. The end to end specification carries a scenario from source through the printed text and back through the Screenplay compiler, because a scenario is the one construct where the value of a property and the name of a step sit one indentation apart - and a document nobody reads back is a document that compiles by luck. Co-Authored-By: Claude Opus 5 --- .../Screenplay.Specs/IntegrationTesting.cs | 111 ++++++++++++++++++ .../a_specification_against_a_running_host.cs | 87 ++++++++++++++ .../a_specification_expecting_a_rejection.cs | 93 +++++++++++++++ .../a_specification_of_a_collaborator.cs | 78 ++++++++++++ .../a_specification_of_a_slice.cs | 91 ++++++++++++++ ...cification_stating_a_value_that_is_code.cs | 82 +++++++++++++ ...pecification_whose_steps_cannot_be_read.cs | 110 +++++++++++++++++ ..._carrying_the_specifications_of_a_slice.cs | 96 +++++++++++++++ 8 files changed, 748 insertions(+) create mode 100644 Source/DotNET/Screenplay.Specs/IntegrationTesting.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs diff --git a/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs new file mode 100644 index 000000000..ce10be200 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs @@ -0,0 +1,111 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay; + +/// +/// Declares the testing surface a Chronicle integration specification is written against, as source. +/// +/// +/// The generator recognizes these by the fully qualified name of the type declaring each member, which is exactly +/// what lets it read a specification without the testing packages being referenced. Declaring them here rather than +/// referencing them keeps every specification about reading them hermetic, and asks the recognition the only +/// question worth asking of it - whether the names alone are enough. +/// +public static class IntegrationTesting +{ + /// + /// The path the surface is compiled as. + /// + public const string Path = "Library/Testing/IntegrationTesting.cs"; + + /// + /// The source of the surface. + /// + public const string Source = """ + using System.Net.Http; + using System.Threading.Tasks; + using Cratis.Chronicle.Events; + using Cratis.Chronicle.EventSequences; + + namespace Cratis.Arc.Testing.Commands + { + public class CommandScenario + { + public Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioChronicleGivenBuilder Given => new(); + + public IEventSequence EventSequence => null!; + + public Task Execute(TCommand command) => Task.FromResult(new Result()); + + public Task Validate(TCommand command) => Task.FromResult(new Result()); + } + + public class Result + { + public bool IsSuccess => true; + } + } + + namespace Cratis.Arc.Chronicle.Testing.Commands + { + public class CommandScenarioChronicleGivenBuilder + { + public CommandScenarioSourceGivenBuilder ForEventSource(EventSourceId eventSourceId) => new(); + } + + public class CommandScenarioSourceGivenBuilder + { + public void Events(params object[] events) + { + } + + public void ReadModel(TReadModel readModel) + where TReadModel : class + { + } + } + } + + namespace Cratis.Chronicle.XUnit.Integration + { + public static class HttpClientExtensions + { + public static Task ExecuteCommand( + this HttpClient client, + string requestUri, + TCommand command) => Task.FromResult(new Cratis.Arc.Testing.Commands.Result()); + } + } + + namespace Cratis.Chronicle.Testing.EventSequences + { + public static class EventSequenceShouldExtensions + { + public static void ShouldHaveAppendedEvent(this IEventSequence sequence, EventSourceId eventSourceId) + { + } + + public static void ShouldHaveTailSequenceNumber(this IEventSequence sequence, int expected) + { + } + + public static void ShouldBeSuccessful(this Cratis.Arc.Testing.Commands.Result result) + { + } + + public static void ShouldNotBeSuccessful(this Cratis.Arc.Testing.Commands.Result result) + { + } + + public static void ShouldHaveConstraintViolationFor(this Cratis.Arc.Testing.Commands.Result result, string constraintName) + { + } + + public static void ShouldBeFalse(this bool value) + { + } + } + } + """; +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs new file mode 100644 index 000000000..f3e6a15e2 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// The other shape Arc documents drives a running host rather than the pipeline in process: what the event log +/// already held is appended to it directly, the command goes over HTTP, and the steps live on a nested context with +/// only the assertions left outside. It says the same thing as the in-process shape and has to read as the same +/// thing, or a document would describe an application differently depending on how it was specified. +/// +public class a_specification_against_a_running_host : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Net.Http; + using System.Threading.Tasks; + using Cratis.Chronicle.EventSequences; + using Cratis.Chronicle.Testing.EventSequences; + using Cratis.Chronicle.XUnit.Integration; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_name_is_already_taken + { + public class context + { + public const string AuthorName = "Jane Austen"; + + protected IEventLog EventLog = null!; + protected HttpClient Client = null!; + + async Task Establish() => await EventLog.Append("author", new AuthorRegistered(AuthorName)); + + async Task Because() => await Client.ExecuteCommand("/api/authors/register", new RegisterAuthor(AuthorName)); + } + + readonly IEventSequence _sequence = null!; + + [Fact] void should_register_the_author() => _sequence.ShouldHaveAppendedEvent("author"); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_name_is_already_taken.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _specification; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single(); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_read_the_steps_written_on_the_nested_context() => _specification.Given.Single().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_state_the_value_appended_to_the_log() => _specification.Given.Single().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]); + [Fact] void should_issue_the_command_sent_over_http() => _specification.When.Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_state_the_values_the_command_was_sent_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]); + [Fact] void should_read_the_assertions_left_outside_the_context() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs new file mode 100644 index 000000000..4d21f9698 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A command being turned down is half of what a slice is specified for, and the language says it as a rejection +/// with a reason. A source naming the reason - the constraint that held - has it carried across; a source saying +/// only that the command did not go through has no reason to carry, and a made up sentence would describe an +/// application nobody wrote. The scenario is named after the words the source uses for it, so the reason is already +/// said there. +/// +public class a_specification_expecting_a_rejection : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_name_is_taken + { + public const string UniqueAuthorName = "unique-author-name"; + + readonly CommandScenario _scenario = new(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen")); + + [Fact] void should_not_register_the_author() => _result.ShouldHaveConstraintViolationFor(UniqueAuthorName); + } + + public class and_nothing_was_given + { + readonly CommandScenario _scenario = new(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen")); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + [Fact] void should_say_so_on_the_result() => _result.IsSuccess.ShouldBeFalse(); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_name_is_taken.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _named; + SpecificationModel _unnamed; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + var specifications = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ToList(); + _named = specifications.Single(_ => _.Name.EndsWith("and_the_name_is_taken", StringComparison.Ordinal)); + _unnamed = specifications.Single(_ => _.Name.EndsWith("and_nothing_was_given", StringComparison.Ordinal)); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_read_both_scenarios_written_in_one_file() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(2); + [Fact] void should_carry_across_the_reason_the_source_names() => _named.Errors.ShouldContainOnly(["unique-author-name"]); + [Fact] void should_expect_no_event_alongside_a_rejection() => _named.Then.ShouldBeEmpty(); + [Fact] void should_state_a_rejection_the_source_names_no_reason_for() => _unnamed.Errors.ShouldContainOnly([string.Empty]); + [Fact] void should_state_two_ways_of_saying_the_same_rejection_once() => _unnamed.Errors.Count().ShouldEqual(1); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs new file mode 100644 index 000000000..e7e32794e --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs @@ -0,0 +1,78 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// Most of the specifications an application carries stand a collaborator up behind a substitute and say what it was +/// asked to do. That is a statement about the inside of a slice rather than about its behavior, so the language has +/// nowhere to put it - and reporting each one would say a gap exists where none does. They are passed over entirely, +/// which is only safe because what is read is decided by what a specification touches rather than by where it sits. +/// +public class a_specification_of_a_collaborator : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public interface INameFormatter + { + string Format(string name); + } + + public class and_the_name_is_formatted + { + string _result = string.Empty; + + void Because() => _result = new Formatter().Format("jane austen"); + + [Fact] void should_capitalize_the_name() => _result.ShouldEqualTheExpected("Jane Austen"); + } + + public class Formatter : INameFormatter + { + public string Format(string name) => name; + } + + public static class Assertions + { + public static void ShouldEqualTheExpected(this string actual, string expected) + { + } + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_name_is_formatted.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(_sources); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_specify_the_slice_by_nothing() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty(); + [Fact] void should_report_no_scenario_left_out() => _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification).ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs new file mode 100644 index 000000000..bc8d41d10 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs @@ -0,0 +1,91 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// The scenarios a slice is specified by sit in a folder beneath it, which is a namespace beneath its own, and are +/// written to a convention rigid enough to read: what had happened is stated against the scenario, the command is +/// issued through it, and each assertion says one thing about what followed. Reading them is what turns a document +/// stating what a slice does into one carrying the examples proving it. +/// +public class a_specification_of_a_slice : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Arc.Queries.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [ReadModel] + public record Author(string Id, string Name); + + [Command] + public record RegisterAuthor(string Name, int Age) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_author_is_new + { + readonly CommandScenario _scenario = new(); + + void Establish() + { + _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen")); + _scenario.Given.ForEventSource("author").ReadModel(new Author("author", "Jane Austen")); + } + + async Task Because() => await _scenario.Execute(new RegisterAuthor("Mary Shelley", 42)); + + [Fact] void should_register_the_author() => _scenario.EventSequence.ShouldHaveAppendedEvent("author"); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_author_is_new.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _specification; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single(); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_put_it_under_the_slice_it_specifies() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(1); + [Fact] void should_name_it_after_every_word_between_the_slice_and_itself() => _specification.Name.ShouldEqual("when_registering_and_the_author_is_new"); + [Fact] void should_start_from_the_event_that_had_happened() => _specification.Given.First().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_know_the_event_it_starts_from_is_an_event() => _specification.Given.First().Kind.ShouldEqual(SpecificationStateKind.Event); + [Fact] void should_state_the_values_of_the_event_it_starts_from() => _specification.Given.First().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]); + [Fact] void should_start_from_the_read_model_that_was_pinned() => _specification.Given.Last().Kind.ShouldEqual(SpecificationStateKind.ReadModel); + [Fact] void should_state_the_values_of_the_read_model() => _specification.Given.Last().Values.Select(_ => _.Property).ShouldContainOnly(["Id", "Name"]); + [Fact] void should_issue_the_command() => _specification.When.Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_state_the_values_the_command_was_issued_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Mary Shelley")), new PropertyMappingModel("Age", new LiteralSource(42))]); + [Fact] void should_expect_the_event_the_assertion_names() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered"); + [Fact] void should_expect_no_rejection() => _specification.Errors.ShouldBeEmpty(); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs new file mode 100644 index 000000000..70f7c6d4e --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A scenario written in the host language routinely rests an identity on a value made at run time, which is exactly +/// what a document stating values has no way to name. Unlike a step, a value stands on its own: leaving one out +/// leaves the rest of the scenario saying what the source says, so it is left out and said rather than taking the +/// scenario with it. +/// +public class a_specification_stating_a_value_that_is_code : Specification +{ + const string Slice = """ + using System; + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(Guid Id, string Name, int Age) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System; + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_identity_is_made_at_run_time + { + readonly CommandScenario _scenario = new(); + readonly Guid _id = Guid.NewGuid(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_id, "Jane Austen", 41)); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_identity_is_made_at_run_time.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + SpecificationModel _specification; + + void Establish() + { + _analysis = Analyzed.Source(_sources); + _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single(); + } + + ScreenplayDiagnostic LeftOut() => + _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecificationValue); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_keep_the_scenario() => _specification.When.Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_state_every_value_it_can_read() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen")), new PropertyMappingModel("Age", new LiteralSource(41))]); + [Fact] void should_leave_out_the_one_it_cannot() => _specification.When.Values.Select(_ => _.Property).ShouldNotContain("Id"); + [Fact] void should_say_which_value_it_left_out() => LeftOut().Message.ShouldEqual("The value 'when_registering_and_the_identity_is_made_at_run_time' states for 'RegisterAuthor.Id' is code rather than a constant, so the scenario states everything but that value"); + [Fact] void should_say_where_it_left_it_out() => LeftOut().Location.ShouldEqual("Library.Authors.Registration.when_registering.and_the_identity_is_made_at_run_time"); + [Fact] void should_report_it_as_information() => LeftOut().Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Information); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs new file mode 100644 index 000000000..1bace393b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs @@ -0,0 +1,110 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A scenario is one example, and an example missing the state it started from, the command it issued or the outcome +/// it expects is a different example from the one the source states. Each of the three is left out whole and said +/// so, which is the difference between a document with a known gap and one that is quietly wrong about what an +/// application was specified against. +/// +public class a_specification_whose_steps_cannot_be_read : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + [Command] + public record RegisterAuthor(string Name) + { + public AuthorRegistered Handle() => new(Name); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Authors.Registration; + using Xunit; + + namespace Library.Authors.Registration.when_registering; + + public class and_the_command_is_held_in_a_field + { + readonly CommandScenario _scenario = new(); + RegisterAuthor _command = null!; + Result _result = null!; + + void Establish() => _command = new RegisterAuthor("Jane Austen"); + + async Task Because() => _result = await _scenario.Execute(_command); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + } + + public class and_what_it_starts_from_depends_on_a_condition + { + readonly CommandScenario _scenario = new(); + Result _result = null!; + + protected virtual bool AuthorAlreadyRegistered => true; + + void Establish() + { + if (AuthorAlreadyRegistered) + { + _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen")); + } + } + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley")); + + [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful(); + } + + public class and_nothing_it_expects_has_a_place_in_the_language + { + readonly CommandScenario _scenario = new(); + Result _result = null!; + + async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley")); + + [Fact] void should_succeed() => _result.ShouldBeSuccessful(); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Authors/Registration/Registration.cs", Slice), + ("Library/Authors/Registration/when_registering/and_the_command_is_held_in_a_field.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(_sources); + + IEnumerable LeftOut() => + _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification); + + bool SaidOf(string scenario, string reason) => + LeftOut().Any(_ => _.Message.Contains(scenario, StringComparison.Ordinal) && _.Message.Contains(reason, StringComparison.Ordinal)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_leave_out_every_scenario_it_cannot_read() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty(); + [Fact] void should_report_each_of_them() => LeftOut().Count().ShouldEqual(3); + [Fact] void should_say_a_command_put_together_elsewhere_cannot_be_read() => SaidOf("and_the_command_is_held_in_a_field", "the command it issues is put together somewhere this cannot read").ShouldBeTrue(); + [Fact] void should_say_a_starting_point_under_a_condition_cannot_be_read() => SaidOf("and_what_it_starts_from_depends_on_a_condition", "only happens under a condition").ShouldBeTrue(); + [Fact] void should_say_an_outcome_the_language_cannot_hold_cannot_be_read() => SaidOf("and_nothing_it_expects_has_a_place_in_the_language", "it expects no event and no rejection").ShouldBeTrue(); + [Fact] void should_say_where_each_of_them_lives() => LeftOut().Select(_ => _.Location).ShouldContain("Library.Authors.Registration.when_registering.and_the_command_is_held_in_a_field"); + [Fact] void should_report_them_as_warnings() => LeftOut().Select(_ => _.Severity).ShouldContainOnly([ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning]); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs new file mode 100644 index 000000000..336b40592 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs @@ -0,0 +1,96 @@ +// 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; + +namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating; + +/// +/// A scenario recovered from source has to survive being written down: the name it is declared under, the events it +/// starts from, the command it issues and the rejection it expects are each a line the language reads back, and a +/// scenario is the one construct where the value of a property and the name of a step sit one indentation apart. +/// This asks the whole way through, from source to printed text and back again through the compiler. +/// +public class from_source_carrying_the_specifications_of_a_slice : Specification +{ + const string Slice = """ + using Cratis.Arc.Commands.ModelBound; + using Cratis.Arc.Queries.ModelBound; + using Cratis.Chronicle.Events; + + namespace Library.Invoicing.Issuing; + + [EventType] + public record InvoiceIssued(string Number, int Lines); + + [ReadModel] + public record Invoice(string Id, string Number); + + [Command] + public record IssueInvoice(string Number, int Lines) + { + public InvoiceIssued Handle() => new(Number, Lines); + } + """; + + const string Scenario = """ + using System.Threading.Tasks; + using Cratis.Arc.Testing.Commands; + using Cratis.Chronicle.Testing.EventSequences; + using Library.Invoicing.Issuing; + using Xunit; + + namespace Library.Invoicing.Issuing.when_issuing; + + public class and_the_invoice_has_no_lines + { + public const string OneInvoicePerNumber = "one-invoice-per-number"; + + readonly CommandScenario _scenario = new(); + Result _result = null!; + + void Establish() + { + _scenario.Given.ForEventSource("invoice").Events(new InvoiceIssued("2026-1", 3)); + _scenario.Given.ForEventSource("invoice").ReadModel(new Invoice("invoice", "2026-1")); + } + + async Task Because() => _result = await _scenario.Execute(new IssueInvoice("2026-2", 0)); + + [Fact] void should_not_issue_the_invoice() => _result.ShouldHaveConstraintViolationFor(OneInvoicePerNumber); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Invoicing/Issuing/Issuing.cs", Slice), + ("Library/Invoicing/Issuing/when_issuing/and_the_invoice_has_no_lines.cs", Scenario), + (IntegrationTesting.Path, IntegrationTesting.Source) + ]; + + ScreenplayGenerationResult _result; + CompilationResult _compiled; + string _reprinted; + + void Because() + { + _result = new ScreenplayGenerator().Generate(Analyzed.Compile(_sources), new ScreenplayOptions()); + _compiled = new ScreenplayCompiler().Compile(_result.Source); + _reprinted = _compiled.Value is null ? string.Empty : new Cratis.Screenplay.Printing.ScreenplayPrinter().Print(_compiled.Value); + } + + bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue(); + [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _reprinted.ShouldEqual(_result.Source); + [Fact] void should_declare_the_scenario_under_the_words_the_source_wrote() => Says("specification WhenIssuingAndTheInvoiceHasNoLines").ShouldBeTrue(); + [Fact] void should_state_the_event_it_starts_from() => Says("given InvoiceIssued").ShouldBeTrue(); + [Fact] void should_state_the_read_model_it_starts_from() => Says("given readmodel Invoice").ShouldBeTrue(); + [Fact] void should_state_the_values_it_starts_from() => Says("number = \"2026-1\"").ShouldBeTrue(); + [Fact] void should_state_the_command_it_issues() => Says("when IssueInvoice").ShouldBeTrue(); + [Fact] void should_state_the_values_the_command_carries() => Says("lines = 0").ShouldBeTrue(); + [Fact] void should_state_the_rejection_and_the_reason_named_for_it() => Says("then error \"one-invoice-per-number\"").ShouldBeTrue(); + [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue(); +}