Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Documentation/screenplay/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)
Expand Down
7 changes: 4 additions & 3 deletions Documentation/screenplay/specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ specification <Name>
<property> = <value>
then readmodel <ReadModelType>
<property> = <value>
then error "<message>"
then error ["<message>"]
```

- `given <EventType>` — zero or more. Establishes prior state by replaying events onto the slice's event source before the command runs.
- `given readmodel <ReadModelType>` — zero or more. Establishes prior read model state directly, for scenarios where expressing the state as events would be noise.
- `when <CommandType>` — zero or one. The command being exercised. A specification can declare at most one `when`; declaring a second is a compile error.
- `then <EventType>` — zero or more. An event expected to be produced by the command.
- `then readmodel <ReadModelType>` — zero or more. The read model state expected after the command has run and its events have been projected.
- `then error "<message>"` — zero or more. An expected rejection, matching how other Screenplay constructs already use quoted-string literals for messages (see [Commands](commands.md)).
- `then error ["<message>"]` — 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 (`<property> = <value>`) accept the same expressions as `produces` and `capture` mappings — string, number and boolean literals, and `$context.*`/`$env.*` expressions.

Expand Down Expand Up @@ -87,7 +87,8 @@ Both forms combine freely with `given`/`then` events in the same specification
| `when <CommandType>` | The command under test, with its property values. |
| `then <EventType>` | An event expected to be produced by the command. |
| `then readmodel <ReadModelType>` | The read model state expected after the command. |
| `then error "<message>"` | An expected rejection message. |
| `then error` | An expected rejection, for a reason the specification does not name. |
| `then error "<message>"` | An expected rejection with a specific message. |
| `<property> = <value>` | A property value, using the same expression grammar as `produces`/`capture` mappings. |

## Compiling specifications
Expand Down
6 changes: 4 additions & 2 deletions Source/DotNET/Screenplay/Parsing/SpecificationParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -223,7 +225,7 @@ static List<PropertyMappingSyntax> 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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,13 @@ public record SpecificationReadModelSyntax(
SourceLocation Location) : SyntaxNode(Location);

/// <summary>
/// Represents an expected rejection declared with <c>then error "&lt;message&gt;"</c>.
/// Represents an expected rejection declared with <c>then error</c>, optionally naming the reason.
/// </summary>
/// <param name="Name">The expected rejection message.</param>
/// <param name="Name">The expected rejection message, or <c>null</c> when the specification does not name one.</param>
/// <param name="Location">The <see cref="SourceLocation"/> where the node starts in the source text.</param>
public record SpecificationErrorSyntax(string Name, SourceLocation Location) : SyntaxNode(Location);
/// <remarks>
/// A <c>null</c> 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.
/// </remarks>
public record SpecificationErrorSyntax(string? Name, SourceLocation Location) : SyntaxNode(Location);
Original file line number Diff line number Diff line change
@@ -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<ApplicationSyntax> _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<SpecificationErrorSyntax> Errors(string name) =>
_result.Value!.Modules.Single().Features.Single().Slices.Single()
.Specifications.Single(_ => _.Name == name).ThenErrors;
}
Original file line number Diff line number Diff line change
@@ -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<SpecificationErrorSyntax> Errors() =>
_roundtrip.Reparsed.Value!.Modules.Single().Features.Single().Slices.Single()
.Specifications.Single().ThenErrors;
}
Loading