diff --git a/Documentation/screenplay/grammar.md b/Documentation/screenplay/grammar.md index 281a354..3cc6a33 100644 --- a/Documentation/screenplay/grammar.md +++ b/Documentation/screenplay/grammar.md @@ -293,7 +293,7 @@ SpecificationWhen = "when", Ident, NL, SpecificationThen = "then", [ "readmodel" ], Ident, NL, [ INDENT, { PropertyMapping }, DEDENT ] - | "then", "error", StringLiteral, NL ; + | "then", "error", [ StringLiteral ], NL ; (* -------------------------------------------------------------- *) (* Event seeding *) diff --git a/Documentation/screenplay/specifications.md b/Documentation/screenplay/specifications.md index 952cd7c..d5170af 100644 --- a/Documentation/screenplay/specifications.md +++ b/Documentation/screenplay/specifications.md @@ -16,7 +16,7 @@ specification = then readmodel = - then error "" + then error [""] ``` - `given ` — zero or more. Establishes prior state by replaying events onto the slice's event source before the command runs. @@ -24,7 +24,7 @@ specification - `when ` — zero or one. The command being exercised. A specification can declare at most one `when`; declaring a second is a compile error. - `then ` — zero or more. An event expected to be produced by the command. - `then readmodel ` — zero or more. The read model state expected after the command has run and its events have been projected. -- `then error ""` — zero or more. An expected rejection, matching how other Screenplay constructs already use quoted-string literals for messages (see [Commands](commands.md)). +- `then error [""]` — zero or more. An expected rejection. The message is optional: a bare `then error` says the scenario is rejected without naming why, which is how most rejection scenarios read - the reason lives in the specification's own name. Quote a message when the scenario asserts a *specific* one, matching how other Screenplay constructs use quoted-string literals for messages (see [Commands](commands.md)). Property values (` = `) accept the same expressions as `produces` and `capture` mappings — string, number and boolean literals, and `$context.*`/`$env.*` expressions. @@ -87,7 +87,8 @@ Both forms combine freely with `given`/`then` events in the same specification | `when ` | The command under test, with its property values. | | `then ` | An event expected to be produced by the command. | | `then readmodel ` | The read model state expected after the command. | -| `then error ""` | An expected rejection message. | +| `then error` | An expected rejection, for a reason the specification does not name. | +| `then error ""` | An expected rejection with a specific message. | | ` = ` | A property value, using the same expression grammar as `produces`/`capture` mappings. | ## Compiling specifications diff --git a/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs b/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs index 7d2bd5f..066e2b7 100644 --- a/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs +++ b/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs @@ -137,7 +137,9 @@ static void ParseThen( var errorMatch = ThenErrorRegex().Match(line.Content); if (errorMatch.Success) { - thenErrors.Add(new(StringLiteral.Unescape(errorMatch.Groups[1].Value), line.Location)); + thenErrors.Add(new( + errorMatch.Groups[1].Success ? StringLiteral.Unescape(errorMatch.Groups[1].Value) : null, + line.Location)); return; } @@ -223,7 +225,7 @@ static List ParseValues(ParserContext context, SourceLine [GeneratedRegex(@"^then\s+readmodel\s+([A-Z]\w*)$", RegexOptions.None, 1000)] private static partial Regex ThenReadModelRegex(); - [GeneratedRegex("^then\\s+error\\s+\"(" + StringLiteral.BodyPattern + ")\"$", RegexOptions.None, 1000)] + [GeneratedRegex("^then\\s+error(?:\\s+\"(" + StringLiteral.BodyPattern + ")\")?$", RegexOptions.None, 1000)] private static partial Regex ThenErrorRegex(); [GeneratedRegex(@"^([\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs index c3b4152..4d693fd 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs @@ -48,7 +48,7 @@ void WriteSpecification(ScreenplayWriter writer, SpecificationSyntax specificati foreach (var error in specification.ThenErrors) { - writer.Line($"then error {StringLiteral.Quote(error.Name)}"); + writer.Line(error.Name is null ? "then error" : $"then error {StringLiteral.Quote(error.Name)}"); } } } diff --git a/Source/DotNET/Screenplay/Syntax/Specifications/SpecificationSyntax.cs b/Source/DotNET/Screenplay/Syntax/Specifications/SpecificationSyntax.cs index 1e3bc99..da38d8f 100644 --- a/Source/DotNET/Screenplay/Syntax/Specifications/SpecificationSyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/Specifications/SpecificationSyntax.cs @@ -63,8 +63,13 @@ public record SpecificationReadModelSyntax( SourceLocation Location) : SyntaxNode(Location); /// -/// Represents an expected rejection declared with then error "<message>". +/// Represents an expected rejection declared with then error, optionally naming the reason. /// -/// The expected rejection message. +/// The expected rejection message, or null when the specification does not name one. /// The where the node starts in the source text. -public record SpecificationErrorSyntax(string Name, SourceLocation Location) : SyntaxNode(Location); +/// +/// A null name is not an empty message - it says the specification asserts a rejection without +/// naming why, which is the shape most rejection scenarios have. The reason lives in the specification's +/// own name. +/// +public record SpecificationErrorSyntax(string? Name, SourceLocation Location) : SyntaxNode(Location); diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_specification_with_an_unnamed_error.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_specification_with_an_unnamed_error.cs new file mode 100644 index 0000000..6c41371 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_specification_with_an_unnamed_error.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_a_specification_with_an_unnamed_error : given.a_compiler +{ + const string Source = + """ + module Identity + feature Tokens + slice StateChange ExchangeToken + command ExchangeToken + token String + + specification WhenExchangingAndMagicLinkIsNotActive + when ExchangeToken + then error + + specification WhenExchangingAndTokenIsExpired + when ExchangeToken + then error "Token has expired" + then error "" + """; + + CompilationResult _result; + + void Because() => _result = _compiler.Compile(Source); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_leave_an_unnamed_reason_unnamed() => Errors("WhenExchangingAndMagicLinkIsNotActive").Single().Name.ShouldBeNull(); + [Fact] void should_keep_a_named_reason() => Errors("WhenExchangingAndTokenIsExpired").First().Name.ShouldEqual("Token has expired"); + [Fact] void should_keep_an_empty_reason_distinct_from_an_unnamed_one() => Errors("WhenExchangingAndTokenIsExpired").Last().Name.ShouldEqual(string.Empty); + [Fact] void should_allow_both_forms_in_one_specification() => Errors("WhenExchangingAndTokenIsExpired").Count().ShouldEqual(2); + + IEnumerable Errors(string name) => + _result.Value!.Modules.Single().Features.Single().Slices.Single() + .Specifications.Single(_ => _.Name == name).ThenErrors; +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_specification_with_an_unnamed_error.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_specification_with_an_unnamed_error.cs new file mode 100644 index 0000000..dff02d2 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_specification_with_an_unnamed_error.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Specifications; + +namespace Cratis.Screenplay.for_ScreenplayPrinter; + +public class when_printing_a_specification_with_an_unnamed_error : given.a_printer +{ + const string Source = + """ + module Identity + feature Tokens + slice StateChange ExchangeToken + command ExchangeToken + token String + + specification WhenExchangingAndMagicLinkIsNotActive + when ExchangeToken + then error + then error "Token has expired" + """; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + [Fact] void should_print_an_unnamed_reason_without_quotes() => _roundtrip.Printed.ShouldContain("then error\n"); + [Fact] void should_print_a_named_reason_with_quotes() => _roundtrip.Printed.ShouldContain("then error \"Token has expired\""); + [Fact] void should_not_turn_an_unnamed_reason_into_an_empty_one() => Errors().First().Name.ShouldBeNull(); + [Fact] void should_preserve_the_named_reason() => Errors().Last().Name.ShouldEqual("Token has expired"); + + IEnumerable Errors() => + _roundtrip.Reparsed.Value!.Modules.Single().Features.Single().Slices.Single() + .Specifications.Single().ThenErrors; +}