From c48fd37c7f81834683d1992ff3ef24bf7bc3ecae Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 11:30:47 +0200 Subject: [PATCH 1/6] Let the language describe what code used to have to Five gaps where a document could not say something the application it describes actually does. A reactor trigger required a 'file' or an inline block, so the one construct whose meaning lived entirely in the escape hatch could not be written before the code existed. The body is now optional, and 'produces ' and 'executes ' state what the reaction does - resolved against the document's events and commands the same way command produces always has been. A slice could declare at most one projection, so a slice that keeps its decision model beside its view model had to drop one or invent a slice that does not exist. SliceSyntax.Projection becomes Projections. A query could not say whether it is live, though a reader binding to it has to build something different in each case. An 'observable' directive says so; unmarked stays one-shot. A second 'by' parameter silently overwrote the first and now reports. Concept attributes were bare markers, so the reason a value is personal data - purpose, lawful basis - had nowhere to go. Any attribute takes an optional quoted argument, and a bare attribute stays valid. A validation rule could not say that it only applies sometimes, and a bare path operand parsed without any stated meaning. Rules take a 'when' condition reusing the produces-when grammar, 'today' becomes a distinct node rather than a path indistinguishable from a property of that name, and a path operand is specified to resolve against the validated artifact's sibling properties. --- .../DotNET/Screenplay/Parsing/QueryParser.cs | 29 +++++- .../Screenplay/Parsing/ReactorParser.cs | 97 +++++++++++++++---- .../Screenplay/Parsing/ScreenplayParser.cs | 14 ++- .../Screenplay/Parsing/ScreenplayValidator.cs | 23 ++++- .../DotNET/Screenplay/Parsing/SliceParser.cs | 13 +-- .../Parsing/ValidationRuleParser.cs | 61 +++++++++++- .../Printing/ScreenplayPrinter.Commands.cs | 15 +++ .../Screenplay/Printing/ScreenplayPrinter.cs | 7 +- .../Printing/ScreenplaySyntaxText.cs | 32 +++--- .../DotNET/Screenplay/Syntax/CommandSyntax.cs | 4 +- .../DotNET/Screenplay/Syntax/ConceptSyntax.cs | 16 ++- .../DotNET/Screenplay/Syntax/Expressions.cs | 10 ++ .../DotNET/Screenplay/Syntax/QuerySyntax.cs | 8 +- .../DotNET/Screenplay/Syntax/ReactorSyntax.cs | 29 +++++- .../DotNET/Screenplay/Syntax/SliceSyntax.cs | 4 +- .../for_ScreenplayCompiler/invoicing.play | 9 +- .../when_compiling_the_invoicing_sample.cs | 25 ++--- .../when_compiling_with_composed_visitors.cs | 2 +- .../when_printing_the_invoicing_sample.cs | 2 +- 19 files changed, 320 insertions(+), 80 deletions(-) diff --git a/Source/DotNET/Screenplay/Parsing/QueryParser.cs b/Source/DotNET/Screenplay/Parsing/QueryParser.cs index 6fa9795..ce85c06 100644 --- a/Source/DotNET/Screenplay/Parsing/QueryParser.cs +++ b/Source/DotNET/Screenplay/Parsing/QueryParser.cs @@ -31,6 +31,7 @@ public static QuerySyntax Parse(ParserContext context, SourceLine header) QueryParameterSyntax? by = null; var filters = new List(); AuthorizeSyntax? authorize = null; + var isObservable = false; while (context.TryPeekChild(header.Indent, out var line)) { @@ -38,8 +39,17 @@ public static QuerySyntax Parse(ParserContext context, SourceLine header) switch (LineText.FirstWord(line.Content)) { case "by": + if (by is not null) + { + context.Error($"Query '{match.Groups[1].Value}' already declares a 'by' parameter - a query can have at most one", line.Location); + break; + } + by = ParseParameter(context, line, "by"); break; + case "observable": + isObservable = ParseObservable(context, line, isObservable, match.Groups[1].Value); + break; case "filter": if (ParseParameter(context, line, "filter") is { } filter) { @@ -57,7 +67,24 @@ public static QuerySyntax Parse(ParserContext context, SourceLine header) } } - return new(match.Groups[1].Value, returnType, by, filters, authorize, header.Location); + return new(match.Groups[1].Value, returnType, by, filters, authorize, header.Location, isObservable); + } + + static bool ParseObservable(ParserContext context, SourceLine line, bool existing, string queryName) + { + if (line.Content != "observable") + { + context.Error($"Invalid observable declaration '{line.Content}' - expected 'observable'", line.Location); + context.SkipBlock(line.Indent); + return existing; + } + + if (existing) + { + context.Error($"Query '{queryName}' already declares 'observable' - declare it at most once", line.Location); + } + + return true; } static QueryParameterSyntax? ParseParameter(ParserContext context, SourceLine line, string keyword) diff --git a/Source/DotNET/Screenplay/Parsing/ReactorParser.cs b/Source/DotNET/Screenplay/Parsing/ReactorParser.cs index cf73c91..9d9979e 100644 --- a/Source/DotNET/Screenplay/Parsing/ReactorParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ReactorParser.cs @@ -37,8 +37,8 @@ public static ReactorSyntax Parse(ParserContext context, SourceLine header) continue; } - var (file, code) = ParseBody(context, line); - triggers.Add(new(trigger.Groups[1].Value, file, code, line.Location)); + var body = ParseBody(context, line); + triggers.Add(new(trigger.Groups[1].Value, body.File, body.Code, line.Location, body.Produces, body.Executes)); } if (triggers.Count == 0) @@ -49,35 +49,77 @@ public static ReactorSyntax Parse(ParserContext context, SourceLine header) return new(name.Groups[1].Value, triggers, header.Location); } - static (FileReferenceSyntax? File, CodeBlockSyntax? Code) ParseBody(ParserContext context, SourceLine trigger) + /// + /// Parses the body of an on trigger. + /// + /// The to parse in. + /// The holding the trigger. + /// The realization the trigger declares, if any. + /// + /// A trigger with no body is valid - on InvitationAccepted states the intent that this reactor + /// observes the event, which a document written before any code exists needs to be able to say. The + /// file reference and the inline block are realization metadata, not the source of meaning. + /// + static TriggerBody ParseBody(ParserContext context, SourceLine trigger) { - FileReferenceSyntax? file = null; - CodeBlockSyntax? code = null; + var body = new TriggerBody(); while (context.TryPeekChild(trigger.Indent, out var line)) { context.Reader.TakeSignificant(); - if (LineText.FirstWord(line.Content) == "file") - { - file = new(line.Content["file".Length..].Trim(), line.Location); - } - else if (CodeBlockParser.Languages.Contains(line.Content)) - { - code = CodeBlockParser.Parse(context, line.Content, line); - } - else + switch (LineText.FirstWord(line.Content)) { - context.Error($"Unexpected '{line.Content}' in reactor trigger - expected 'file ' or an inline code block", line.Location); - context.SkipBlock(line.Indent); + case "file": + body.File = new(line.Content["file".Length..].Trim(), line.Location); + break; + case "produces": + AddProduces(context, body, line); + break; + case "executes": + AddExecutes(context, body, line); + break; + default: + if (CodeBlockParser.Languages.Contains(line.Content)) + { + body.Code = CodeBlockParser.Parse(context, line.Content, line); + break; + } + + context.Error( + $"Unexpected '{line.Content}' in reactor trigger - expected 'produces ', 'executes ', 'file ' or an inline code block", + line.Location); + context.SkipBlock(line.Indent); + break; } } - if (file is null && code is null) + return body; + } + + static void AddProduces(ParserContext context, TriggerBody body, SourceLine line) + { + var match = ProducesRegex().Match(line.Content); + if (!match.Success) + { + context.Error($"Invalid produces declaration '{line.Content}' - expected 'produces '", line.Location); + context.SkipBlock(line.Indent); + return; + } + + body.Produces.Add(new(match.Groups[1].Value, line.Location)); + } + + static void AddExecutes(ParserContext context, TriggerBody body, SourceLine line) + { + var match = ExecutesRegex().Match(line.Content); + if (!match.Success) { - context.Error("Reactor trigger must have a 'file' directive or an inline code block", trigger.Location); + context.Error($"Invalid executes declaration '{line.Content}' - expected 'executes '", line.Location); + context.SkipBlock(line.Indent); + return; } - return (file, code); + body.Executes.Add(new(match.Groups[1].Value, line.Location)); } [GeneratedRegex(@"^reactor\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)] @@ -85,4 +127,21 @@ public static ReactorSyntax Parse(ParserContext context, SourceLine header) [GeneratedRegex(@"^on\s+([A-Z]\w*)$", RegexOptions.None, 1000)] private static partial Regex OnRegex(); + + [GeneratedRegex(@"^produces\s+([A-Z]\w*)$", RegexOptions.None, 1000)] + private static partial Regex ProducesRegex(); + + [GeneratedRegex(@"^executes\s+([A-Z]\w*)$", RegexOptions.None, 1000)] + private static partial Regex ExecutesRegex(); + + sealed class TriggerBody + { + public FileReferenceSyntax? File { get; set; } + + public CodeBlockSyntax? Code { get; set; } + + public List Produces { get; } = []; + + public List Executes { get; } = []; + } } diff --git a/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs b/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs index d6ed598..7cd4d85 100644 --- a/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -131,9 +132,11 @@ static ConceptSyntax ParseConcept(ParserContext context, SourceLine line) var name = match.Groups[1].Value; var type = match.Groups[2].Value; - var attributes = match.Groups[3].Value - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(_ => _.TrimStart('@')) + var attributes = AttributeRegex().Matches(match.Groups[3].Value) + .Select(attribute => new ConceptAttributeSyntax( + attribute.Groups[1].Value, + attribute.Groups[2].Success ? StringLiteral.Unescape(attribute.Groups[2].Value) : null, + line.Location)) .ToList(); if (type != "Enum" && !ConceptSyntax.PrimitiveTypes.Contains(type)) @@ -332,9 +335,12 @@ static FeatureSyntax ParseFeature(ParserContext context, SourceLine line) [GeneratedRegex(@"^import\s+([\w.]+)$", RegexOptions.None, 1000)] private static partial Regex ImportRegex(); - [GeneratedRegex(@"^concept\s+(\w+)\s*:\s*(\w+)((?:\s+@\w+)*)$", RegexOptions.None, 1000)] + [GeneratedRegex(@"^concept\s+(\w+)\s*:\s*(\w+)((?:\s+@\w+(?:\(""" + StringLiteral.BodyPattern + @"""\))?)*)$", RegexOptions.None, 1000)] private static partial Regex ConceptRegex(); + [GeneratedRegex(@"@(\w+)(?:\(""(" + StringLiteral.BodyPattern + @")""\))?", RegexOptions.None, 1000)] + private static partial Regex AttributeRegex(); + [GeneratedRegex(@"^@?[a-z_]\w*$", RegexOptions.None, 1000)] private static partial Regex EnumValueRegex(); diff --git a/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs b/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs index d90cacb..2ffadbb 100644 --- a/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs +++ b/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs @@ -28,6 +28,9 @@ public static void Validate(ApplicationSyntax application, ParserContext context .Concat(application.Imports.Select(import => import.Name)) .ToHashSet(); var knownPolicies = application.Policies.Select(policy => policy.Name).ToHashSet(); + var knownCommands = slices.SelectMany(slice => slice.Commands.Select(command => command.Name)) + .Concat(application.Imports.Select(import => import.Name)) + .ToHashSet(); foreach (var persona in application.Personas ?? []) { @@ -61,14 +64,14 @@ public static void Validate(ApplicationSyntax application, ParserContext context foreach (var slice in slices) { - ValidateSlice(slice, knownEvents, knownPolicies, context); + ValidateSlice(slice, knownEvents, knownPolicies, knownCommands, context); } } static IEnumerable AllFeatures(FeatureSyntax feature) => new[] { feature }.Concat(feature.Features.SelectMany(AllFeatures)); - static void ValidateSlice(SliceSyntax slice, HashSet knownEvents, HashSet knownPolicies, ParserContext context) + static void ValidateSlice(SliceSyntax slice, HashSet knownEvents, HashSet knownPolicies, HashSet knownCommands, ParserContext context) { foreach (var concurrency in slice.Commands.Select(command => command.Concurrency) .OfType() @@ -94,12 +97,24 @@ static void ValidateSlice(SliceSyntax slice, HashSet knownEvents, HashSe context.Warning($"Unknown event '{produces.Event}' - declare it with 'event {produces.Event}'", produces.Location); } - foreach (var trigger in slice.Reactors.SelectMany(reactor => reactor.Triggers) - .Where(trigger => !knownEvents.Contains(trigger.Event))) + var triggers = slice.Reactors.SelectMany(reactor => reactor.Triggers).ToList(); + foreach (var trigger in triggers.Where(trigger => !knownEvents.Contains(trigger.Event))) { context.Warning($"Unknown event '{trigger.Event}' - declare it with 'event {trigger.Event}'", trigger.Location); } + foreach (var produces in triggers.SelectMany(trigger => trigger.Produces ?? []) + .Where(produces => !knownEvents.Contains(produces.Event))) + { + context.Warning($"Unknown event '{produces.Event}' - declare it with 'event {produces.Event}'", produces.Location); + } + + foreach (var executes in triggers.SelectMany(trigger => trigger.Executes ?? []) + .Where(executes => !knownCommands.Contains(executes.Command))) + { + context.Warning($"Unknown command '{executes.Command}' - declare it with 'command {executes.Command}'", executes.Location); + } + foreach (var constraint in slice.Constraints) { var @event = constraint switch diff --git a/Source/DotNET/Screenplay/Parsing/SliceParser.cs b/Source/DotNET/Screenplay/Parsing/SliceParser.cs index 1850797..a866dd5 100644 --- a/Source/DotNET/Screenplay/Parsing/SliceParser.cs +++ b/Source/DotNET/Screenplay/Parsing/SliceParser.cs @@ -44,7 +44,7 @@ public static SliceSyntax Parse(ParserContext context, SourceLine header) var events = new List(); var commands = new List(); var queries = new List(); - ProjectionSyntax? projection = null; + var projections = new List(); var captures = new List(); var reactors = new List(); var screens = new List(); @@ -69,14 +69,7 @@ public static SliceSyntax Parse(ParserContext context, SourceLine header) queries.Add(QueryParser.Parse(context, line)); break; case "projection": - if (projection is not null) - { - context.Error($"Slice '{name}' already declares a projection - a slice can have at most one", line.Location); - context.SkipBlock(line.Indent); - break; - } - - projection = ProjectionParser.Parse(context, line); + projections.Add(ProjectionParser.Parse(context, line)); break; case "capture": captures.Add(CaptureParser.Parse(context, line)); @@ -100,7 +93,7 @@ public static SliceSyntax Parse(ParserContext context, SourceLine header) } } - return new(type, name, events, commands, queries, projection, captures, reactors, screens, constraints, specifications, header.Location, description); + return new(type, name, events, commands, queries, projections, captures, reactors, screens, constraints, specifications, header.Location, description); } [GeneratedRegex(@"^slice\s+([A-Za-z]\w*)\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs index d3bf693..3756ea3 100644 --- a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs @@ -21,7 +21,8 @@ internal static partial class ValidationRuleParser public static ValidationRuleSyntax? Parse(ParserContext context, SourceLine line) { var (content, message) = SplitMessage(line.Content); - var match = RuleRegex().Match(content); + var (ruleText, when) = SplitWhen(context, content, line); + var match = RuleRegex().Match(ruleText); if (!match.Success) { context.Error($"Invalid validation rule '{line.Content}'", line.Location); @@ -36,7 +37,7 @@ internal static partial class ValidationRuleParser return null; } - return new(property, kind.Value, value, message, line.Location); + return new(property, kind.Value, value, message, line.Location, when); } /// @@ -49,13 +50,14 @@ internal static partial class ValidationRuleParser public static ValidationRuleSyntax? ParseImpliedSubject(ParserContext context, SourceLine line) { var (content, message) = SplitMessage(line.Content); - var (kind, value) = ParseRule(context, content, line); + var (ruleText, when) = SplitWhen(context, content, line); + var (kind, value) = ParseRule(context, ruleText, line); if (kind is null) { return null; } - return new(ValidationRuleSyntax.ConceptValue, kind.Value, value, message, line.Location); + return new(ValidationRuleSyntax.ConceptValue, kind.Value, value, message, line.Location, when); } static (string Content, string? Message) SplitMessage(string content) @@ -70,6 +72,41 @@ internal static partial class ValidationRuleParser return (content[..match.Index].TrimEnd(), message); } + /// + /// Splits a rule line into the rule itself and the condition under which it applies. + /// + /// The to report diagnostics to. + /// The rule text, with any message already removed. + /// The the rule came from. + /// The rule text and the parsed condition, or null when the rule is unconditional. + /// + /// The split is done on whitespace separated words rather than a regular expression so a when + /// inside a quoted operand - matches "^when$" - is not mistaken for the keyword. + /// + static (string Content, ConditionSyntax? When) SplitWhen(ParserContext context, string content, SourceLine line) + { + var words = LineText.SplitTopLevel(content, ' ').ToList(); + var offset = 0; + for (var index = 0; index < words.Count; index++) + { + if (index > 0 && words[index] == "when") + { + var condition = content[(offset + "when".Length)..].Trim(); + if (condition.Length == 0) + { + context.Error($"Expected a condition after 'when' in validation rule '{line.Content}'", line.Location); + return (content[..offset].TrimEnd(), null); + } + + return (content[..offset].TrimEnd(), ConditionParser.Parse(context, condition, line.Location)); + } + + offset += words[index].Length + 1; + } + + return (content, null); + } + static (ValidationRuleKind? Kind, ExpressionSyntax? Value) ParseRule(ParserContext context, string rule, SourceLine line) { if (rule == "not empty") @@ -84,7 +121,7 @@ internal static partial class ValidationRuleParser return (null, null); } - var value = ExpressionParser.ParseMappingSource(operand.Groups[2].Value, line.Location); + var value = ParseOperand(operand.Groups[2].Value, line); ValidationRuleKind? kind = operand.Groups[1].Value switch { "max" => ValidationRuleKind.Max, @@ -110,6 +147,20 @@ internal static partial class ValidationRuleParser return (kind, value); } + /// + /// Parses a rule operand - a literal, the today keyword, or a path resolving against a sibling + /// property of the validated artifact. + /// + /// The operand text. + /// The the operand came from. + /// The parsed . + static ExpressionSyntax ParseOperand(string text, SourceLine line) => text.Trim() switch + { + "today" => new TodayExpressionSyntax(line.Location), + "@today" => new PathExpressionSyntax("today", line.Location), + _ => ExpressionParser.ParseMappingSource(text, line.Location) + }; + [GeneratedRegex("\\bmessage\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex MessageRegex(); diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs index aa24357..d038a1e 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs @@ -94,6 +94,11 @@ void WriteQuery(ScreenplayWriter writer, QuerySyntax query) writer.Line($"query {query.Name} => {ScreenplaySyntaxText.TypeRef(query.ReturnType)}"); using (writer.Indent()) { + if (query.IsObservable) + { + writer.Line("observable"); + } + if (query.By is not null) { writer.Line($"by {query.By.Name} {ScreenplaySyntaxText.TypeRef(query.By.Type)}"); @@ -141,6 +146,16 @@ void WriteReactor(ScreenplayWriter writer, ReactorSyntax reactor) writer.Line($"on {trigger.Event}"); using (writer.Indent()) { + foreach (var produces in trigger.Produces ?? []) + { + writer.Line($"produces {produces.Event}"); + } + + foreach (var executes in trigger.Executes ?? []) + { + writer.Line($"executes {executes.Command}"); + } + if (trigger.File is not null) { writer.Line($"file {trigger.File.Path}"); diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs index 248435a..7ed5f4b 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs @@ -163,7 +163,8 @@ void WriteSeed(ScreenplayWriter writer, SeedSyntax seed) void WriteConcept(ScreenplayWriter writer, ConceptSyntax concept) { - var attributes = concept.Attributes.Select(attribute => $" @{attribute}"); + var attributes = concept.Attributes.Select(attribute => + attribute.Value is null ? $" @{attribute.Name}" : $" @{attribute.Name}({StringLiteral.Quote(attribute.Value)})"); writer.Line($"concept {concept.Name} : {concept.Type}{string.Concat(attributes)}"); var validations = concept.Validations?.ToList() ?? []; if (!concept.IsEnum && validations.Count == 0) @@ -308,10 +309,10 @@ void WriteSlice(ScreenplayWriter writer, SliceSyntax slice) WriteQuery(writer, query); } - if (slice.Projection is not null) + foreach (var projection in slice.Projections) { writer.Blank(); - WriteProjection(writer, slice.Projection); + WriteProjection(writer, projection); } foreach (var capture in slice.Captures) diff --git a/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs b/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs index 650b404..23dd8fc 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs @@ -35,6 +35,7 @@ internal static partial class ScreenplaySyntaxText SecretExpressionSyntax secret => $"$secrets.{secret.Name}", StringsExpressionSyntax strings => $"$strings.{strings.Key}", SourceItemExpressionSyntax sourceItem => $"$.{sourceItem.Path}", + TodayExpressionSyntax => "today", EventSourceIdExpressionSyntax => "$eventSourceId", EventContextExpressionSyntax eventContext => $"$eventContext.{eventContext.Path}", CausedByExpressionSyntax causedBy => causedBy.Property is null ? "$causedBy" : $"$causedBy.{causedBy.Property}", @@ -82,11 +83,8 @@ public static string TypeRef(TypeRefSyntax type) => /// /// The to render. /// The rendered rule text including any message. - public static string ValidationRule(ValidationRuleSyntax rule) - { - var head = $"{rule.Property} {ValidationRuleBody(rule)}"; - return rule.Message is null ? head : $"{head} message {LocalizableString(rule.Message)}"; - } + public static string ValidationRule(ValidationRuleSyntax rule) => + WithConditionAndMessage($"{rule.Property} {ValidationRuleBody(rule)}", rule); /// /// Renders a declarative validation rule without its property subject - the form used on concepts, @@ -94,11 +92,8 @@ public static string ValidationRule(ValidationRuleSyntax rule) /// /// The to render. /// The rendered rule text including any message. - public static string ImpliedSubjectValidationRule(ValidationRuleSyntax rule) - { - var head = ValidationRuleBody(rule); - return rule.Message is null ? head : $"{head} message {LocalizableString(rule.Message)}"; - } + public static string ImpliedSubjectValidationRule(ValidationRuleSyntax rule) => + WithConditionAndMessage(ValidationRuleBody(rule), rule); /// /// Renders a string operand that may reference a localized string - values starting with @@ -198,9 +193,19 @@ static string ClaimCondition(ClaimConditionSyntax claim) return $"claim {StringLiteral.Quote(claim.Claim)} matches {value}"; } + static string WithConditionAndMessage(string head, ValidationRuleSyntax rule) + { + if (rule.When is not null) + { + head = $"{head} when {Condition(rule.When)}"; + } + + return rule.Message is null ? head : $"{head} message {LocalizableString(rule.Message)}"; + } + static string ValidationRuleBody(ValidationRuleSyntax rule) { - var value = rule.Value is null ? string.Empty : Expression(rule.Value); + var value = rule.Value is null ? string.Empty : RuleOperand(rule.Value); return rule.Rule switch { ValidationRuleKind.NotEmpty => "not empty", @@ -219,6 +224,11 @@ static string ValidationRuleBody(ValidationRuleSyntax rule) }; } + static string RuleOperand(ExpressionSyntax value) => + value is PathExpressionSyntax path && string.Equals(path.Path, "today", StringComparison.Ordinal) + ? "@today" + : Expression(value); + [GeneratedRegex(@"^[\w.]+$", RegexOptions.None, 1000)] private static partial Regex BareWordRegex(); diff --git a/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs b/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs index 7b06a09..f8ccb10 100644 --- a/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs @@ -173,12 +173,14 @@ public record CodeValidateSyntax(CodeBlockSyntax Code, SourceLocation Location) /// The operand of the rule when it takes one, such as the limit of max or the value compared against. /// The optional message shown when the rule fails. /// The where the node starts in the source text. +/// The optional that has to hold for the rule to apply. public record ValidationRuleSyntax( string Property, ValidationRuleKind Rule, ExpressionSyntax? Value, string? Message, - SourceLocation Location) : SyntaxNode(Location) + SourceLocation Location, + ConditionSyntax? When = null) : SyntaxNode(Location) { /// /// The well known subject of rules declared on a concept, where the concept's diff --git a/Source/DotNET/Screenplay/Syntax/ConceptSyntax.cs b/Source/DotNET/Screenplay/Syntax/ConceptSyntax.cs index 9e0cb58..d591005 100644 --- a/Source/DotNET/Screenplay/Syntax/ConceptSyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/ConceptSyntax.cs @@ -10,7 +10,7 @@ namespace Cratis.Screenplay.Syntax; /// /// The name of the concept. /// The primitive type of the concept, or Enum for enumeration concepts. -/// The attributes applied to the concept, without the @ prefix, such as pii and sensitive. +/// The attributes applied to the concept, such as @pii and @sensitive. /// The values of the concept when it is an enumeration, empty otherwise. /// The where the node starts in the source text. /// The validation blocks for the concept. Rules use @@ -18,7 +18,7 @@ namespace Cratis.Screenplay.Syntax; public record ConceptSyntax( string Name, string Type, - IEnumerable Attributes, + IEnumerable Attributes, IEnumerable Values, SourceLocation Location, IEnumerable? Validations = null) : SyntaxNode(Location) @@ -33,3 +33,15 @@ public record ConceptSyntax( /// public bool IsEnum => Type == "Enum"; } + +/// +/// Represents an attribute applied to a concept, with the reason it carries when one is declared. +/// +/// The name of the attribute, without the @ prefix - pii, sensitive and so on. +/// The optional argument - for @pii the reason the value is personal data. +/// The where the node starts in the source text. +/// +/// Any @word is accepted, with or without an argument. The compiler does not enumerate the known +/// attributes, so a consumer can introduce its own without a grammar change. +/// +public record ConceptAttributeSyntax(string Name, string? Value, SourceLocation Location) : SyntaxNode(Location); diff --git a/Source/DotNET/Screenplay/Syntax/Expressions.cs b/Source/DotNET/Screenplay/Syntax/Expressions.cs index 6b06f3f..2157150 100644 --- a/Source/DotNET/Screenplay/Syntax/Expressions.cs +++ b/Source/DotNET/Screenplay/Syntax/Expressions.cs @@ -72,3 +72,13 @@ public record RawExpressionSyntax(string Text, SourceLocation Location) : Expres /// The dotted path following $.. /// The where the node starts in the source text. public record SourceItemExpressionSyntax(string Path, SourceLocation Location) : ExpressionSyntax(Location); + +/// +/// Represents the today keyword - the current date, evaluated when the rule runs. +/// +/// The where the node starts in the source text. +/// +/// A distinct node so a consumer can tell the keyword from a property that happens to be called +/// today; the property is written @today. +/// +public record TodayExpressionSyntax(SourceLocation Location) : ExpressionSyntax(Location); diff --git a/Source/DotNET/Screenplay/Syntax/QuerySyntax.cs b/Source/DotNET/Screenplay/Syntax/QuerySyntax.cs index 9a8c20b..7b7c462 100644 --- a/Source/DotNET/Screenplay/Syntax/QuerySyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/QuerySyntax.cs @@ -14,13 +14,19 @@ namespace Cratis.Screenplay.Syntax; /// The narrowing parameters declared with filter. /// The optional for the query. /// The where the node starts in the source text. +/// Whether the query is observable - a live subscription rather than a one-shot read. +/// +/// A query is one-shot unless it declares observable, so a document written before the marker existed +/// keeps its meaning. +/// public record QuerySyntax( string Name, TypeRefSyntax ReturnType, QueryParameterSyntax? By, IEnumerable Filters, AuthorizeSyntax? Authorize, - SourceLocation Location) : SyntaxNode(Location); + SourceLocation Location, + bool IsObservable = false) : SyntaxNode(Location); /// /// Represents a parameter of a query. diff --git a/Source/DotNET/Screenplay/Syntax/ReactorSyntax.cs b/Source/DotNET/Screenplay/Syntax/ReactorSyntax.cs index 3939d60..98b78d8 100644 --- a/Source/DotNET/Screenplay/Syntax/ReactorSyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/ReactorSyntax.cs @@ -14,14 +14,39 @@ namespace Cratis.Screenplay.Syntax; public record ReactorSyntax(string Name, IEnumerable Triggers, SourceLocation Location) : SyntaxNode(Location); /// -/// Represents an on <event> trigger within a reactor, with its implementation. +/// Represents an on <event> trigger within a reactor, with what it produces and its implementation. /// /// The name of the event that triggers the reactor. /// The when the implementation lives in an external file. /// The when the implementation is declared inline. /// The where the node starts in the source text. +/// The events the trigger appends as side effects. +/// The commands the trigger executes. +/// +/// Everything below the trigger is optional. A trigger with nothing under it states that the reactor +/// observes the event; produces and executes complete the declarative description of what it +/// does, and file or an inline block attaches the realization. +/// public record ReactorTriggerSyntax( string Event, FileReferenceSyntax? File, CodeBlockSyntax? Code, - SourceLocation Location) : SyntaxNode(Location); + SourceLocation Location, + IEnumerable? Produces = null, + IEnumerable? Executes = null) : SyntaxNode(Location); + +/// +/// Represents a produces <Event> declaration under a reactor trigger - an event the reactor +/// appends as a side effect. +/// +/// The name of the produced event. +/// The where the node starts in the source text. +public record ReactorProducesSyntax(string Event, SourceLocation Location) : SyntaxNode(Location); + +/// +/// Represents an executes <Command> declaration under a reactor trigger - a command the reactor +/// puts through the command pipeline. +/// +/// The name of the executed command. +/// The where the node starts in the source text. +public record ReactorExecutesSyntax(string Command, SourceLocation Location) : SyntaxNode(Location); diff --git a/Source/DotNET/Screenplay/Syntax/SliceSyntax.cs b/Source/DotNET/Screenplay/Syntax/SliceSyntax.cs index 43faf6a..6aba809 100644 --- a/Source/DotNET/Screenplay/Syntax/SliceSyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/SliceSyntax.cs @@ -42,7 +42,7 @@ public enum SliceType /// The events declared in the slice. /// The commands declared in the slice. /// The queries declared in the slice. -/// The optional declared in the slice. +/// The projections declared in the slice. /// The captures declared in the slice. /// The reactors declared in the slice. /// The screens declared in the slice. @@ -56,7 +56,7 @@ public record SliceSyntax( IEnumerable Events, IEnumerable Commands, IEnumerable Queries, - ProjectionSyntax? Projection, + IEnumerable Projections, IEnumerable Captures, IEnumerable Reactors, IEnumerable Screens, diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play b/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play index d6fc3ef..381b348 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play @@ -24,7 +24,7 @@ concept Quantity : Int concept PersonName : String @pii concept BankAccount : String @pii @sensitive -concept EmailAddress : String @pii +concept EmailAddress : String @pii("Contact address for invoice delivery. Lawful basis: contract performance.") validate not empty message "Email is required" matches "^.+@.+$" message "Must be a valid email address" @@ -142,6 +142,7 @@ module Invoicing lines.unitPrice all >= 0 message "Line prices cannot be negative" currency length == 3 message "Currency must be a 3-letter ISO code" dueDate > today message "Due date must be in the future" + paymentTerms == "immediate" when isProForma == true message "Pro forma invoices are payable immediately" validate csharp ``` @@ -552,6 +553,7 @@ module Invoicing slice StateView InvoiceList query ListInvoices => InvoiceListReadModel[] + observable filter status InvoiceStatus? filter customerId CustomerId? authorize IsAuthenticated @@ -781,12 +783,17 @@ module Invoicing reactor NotifyCustomer on InvoiceRegistered + produces CustomerNotified file Reactors/NotifyCustomerReactor.cs + event CustomerNotified + invoiceId InvoiceId + slice Automation DetectOverdueInvoices reactor OverdueInvoiceDetector on InvoiceRegistered + executes ChangeInvoiceStatus csharp ``` if (DueDate < DateTimeOffset.UtcNow) diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs index daae6dd..5af2feb 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs @@ -26,11 +26,11 @@ void Because() [Fact] void should_have_the_customer_import() => _result.Value!.Imports.Select(_ => _.Name).ShouldContain("CustomerRegistered"); [Fact] void should_have_all_concepts() => _result.Value!.Concepts.Count().ShouldEqual(12); [Fact] void should_have_the_enum_concept_values() => _result.Value!.Concepts.Single(_ => _.Name == "InvoiceStatus").Values.Count().ShouldEqual(5); - [Fact] void should_capture_pii_attributes() => _result.Value!.Concepts.Single(_ => _.Name == "EmailAddress").Attributes.ShouldContain("pii"); + [Fact] void should_capture_pii_attributes() => _result.Value!.Concepts.Single(_ => _.Name == "EmailAddress").Attributes.Select(_ => _.Name).ShouldContain("pii"); [Fact] void should_parse_the_concept_validation_rules() => EmailConceptRules.Count().ShouldEqual(2); [Fact] void should_imply_the_concept_value_subject() => EmailConceptRules.All(_ => _.Property == ValidationRuleSyntax.ConceptValue).ShouldBeTrue(); [Fact] void should_have_all_policies() => _result.Value!.Policies.Count().ShouldEqual(7); - [Fact] void should_have_the_sensitive_concept_attributes() => _result.Value!.Concepts.Single(_ => _.Name == "BankAccount").Attributes.ShouldContainOnly("pii", "sensitive"); + [Fact] void should_have_the_sensitive_concept_attributes() => _result.Value!.Concepts.Single(_ => _.Name == "BankAccount").Attributes.Select(_ => _.Name).ShouldContainOnly("pii", "sensitive"); [Fact] void should_parse_the_code_based_policy() => _result.Value!.Policies.Single(_ => _.Name == "IsAdultCustomer").Code.ShouldNotBeNull(); [Fact] void should_have_both_personas() => _result.Value!.Personas!.Count().ShouldEqual(2); [Fact] void should_have_the_seed_block() => _result.Value!.Seeds!.Single().Groups.Count().ShouldEqual(2); @@ -46,7 +46,7 @@ void Because() [Fact] void should_have_both_layouts() => _result.Value!.Modules.Single().Layouts.Count().ShouldEqual(2); [Fact] void should_have_the_master_detail_slots() => _result.Value!.Modules.Single().Layouts.First().Slots.ShouldContainOnly("sidebar", "main"); [Fact] void should_have_all_slices() => _feature.Slices.Count().ShouldEqual(14); - [Fact] void should_have_the_fully_auto_mapped_projection() => Slice("CancelledInvoices").Projection!.Blocks.OfType().Single().Mappings.ShouldBeEmpty(); + [Fact] void should_have_the_fully_auto_mapped_projection() => Slice("CancelledInvoices").Projections.Single().Blocks.OfType().Single().Mappings.ShouldBeEmpty(); [Fact] void should_have_the_nested_feature() => _feature.Features.Single().Name.ShouldEqual("Adjustments"); [Fact] void should_have_the_nested_feature_slices() => _feature.Features.Single().Slices.Select(_ => _.Name).ShouldContainOnly("ApplyDiscount", "WriteOffInvoice"); [Fact] void should_parse_conditional_produces() => RegisterCommand.Produces.Count(_ => _.When is not null).ShouldEqual(4); @@ -55,7 +55,8 @@ void Because() [Fact] void should_parse_the_event_tags() => RegisteredEvent.Tags!.Count().ShouldEqual(3); [Fact] void should_parse_the_static_event_tags() => RegisteredEvent.Tags!.Take(2).Select(_ => ((LiteralExpressionSyntax)_.Value).Value).ShouldContainOnly("invoicing", "billing"); [Fact] void should_parse_the_dynamic_event_tag() => ((ContextExpressionSyntax)RegisteredEvent.Tags!.Last().Value).Path.ShouldEqual("identity.id"); - [Fact] void should_parse_the_validation_rules() => RegisterCommand.Validations.OfType().Single().Rules.Count().ShouldEqual(7); + [Fact] void should_parse_the_conditional_validation_rule() => RegisterCommand.Validations.OfType().Single().Rules.Count(_ => _.When is not null).ShouldEqual(1); + [Fact] void should_parse_the_validation_rules() => RegisterCommand.Validations.OfType().Single().Rules.Count().ShouldEqual(8); [Fact] void should_parse_the_code_validation() => RegisterCommand.Validations.OfType().Count().ShouldEqual(1); [Fact] void should_parse_the_authorize_policies() => RegisterCommand.Authorize!.Policies.Select(_ => _.Name).ShouldContainOnly("CanManageInvoice", "IsAdultCustomer"); [Fact] void should_parse_the_concurrency_event_source() => RegisterCommand.Concurrency!.EventSource.ShouldBeTrue(); @@ -84,14 +85,14 @@ void Because() CaptureSyntax Capture => Slice("LegacyInvoiceSync").Captures.Single(); CaptureWhenSyntax ValueTransitionWhen => Capture.Appends.Single(_ => _.Event == "InvoicePaidFromSent").When!; - [Fact] void should_parse_the_list_projection() => Slice("InvoiceList").Projection!.Blocks.OfType().Single().Event.ShouldEqual("InvoiceCancelled"); - [Fact] void should_parse_the_details_projection_join() => Slice("InvoiceDetails").Projection!.Blocks.OfType().Single().On.ShouldEqual("customerId"); - [Fact] void should_parse_the_details_projection_children() => Slice("InvoiceDetails").Projection!.Blocks.OfType().Single().Property.ShouldEqual("lineItems"); - [Fact] void should_parse_the_details_projection_every() => Slice("InvoiceDetails").Projection!.Blocks.OfType().Single().IncludeChildren.ShouldBeFalse(); - [Fact] void should_parse_the_details_projection_remove_via_join() => Slice("InvoiceDetails").Projection!.Blocks.OfType().Single().Event.ShouldEqual("CustomerAccountClosed"); - [Fact] void should_parse_the_details_projection_nested() => Slice("InvoiceDetails").Projection!.Blocks.OfType().Single().Property.ShouldEqual("shipping"); - [Fact] void should_parse_the_line_report_composite_key() => ((CompositeKeySyntax)Slice("InvoiceLineReport").Projection!.Blocks.OfType().Single().Key!).Type.ShouldEqual("InvoiceLineKey"); - [Fact] void should_parse_the_summary_counters() => Slice("InvoiceDashboard").Projection!.Blocks.OfType().First().Mappings.OfType().Count().ShouldEqual(2); + [Fact] void should_parse_the_list_projection() => Slice("InvoiceList").Projections.Single().Blocks.OfType().Single().Event.ShouldEqual("InvoiceCancelled"); + [Fact] void should_parse_the_details_projection_join() => Slice("InvoiceDetails").Projections.Single().Blocks.OfType().Single().On.ShouldEqual("customerId"); + [Fact] void should_parse_the_details_projection_children() => Slice("InvoiceDetails").Projections.Single().Blocks.OfType().Single().Property.ShouldEqual("lineItems"); + [Fact] void should_parse_the_details_projection_every() => Slice("InvoiceDetails").Projections.Single().Blocks.OfType().Single().IncludeChildren.ShouldBeFalse(); + [Fact] void should_parse_the_details_projection_remove_via_join() => Slice("InvoiceDetails").Projections.Single().Blocks.OfType().Single().Event.ShouldEqual("CustomerAccountClosed"); + [Fact] void should_parse_the_details_projection_nested() => Slice("InvoiceDetails").Projections.Single().Blocks.OfType().Single().Property.ShouldEqual("shipping"); + [Fact] void should_parse_the_line_report_composite_key() => ((CompositeKeySyntax)Slice("InvoiceLineReport").Projections.Single().Blocks.OfType().Single().Key!).Type.ShouldEqual("InvoiceLineKey"); + [Fact] void should_parse_the_summary_counters() => Slice("InvoiceDashboard").Projections.Single().Blocks.OfType().First().Mappings.OfType().Count().ShouldEqual(2); [Fact] void should_parse_the_dashboard_screen_layout() => Slice("InvoiceDashboard").Screens.Single().Directives.OfType().Single().Slots.Count().ShouldEqual(4); [Fact] void should_parse_the_reactors() => Slice("NotifyCustomerOnInvoiceRegistered").Reactors.Single().Triggers.Single().File!.Path.ShouldEqual("Reactors/NotifyCustomerReactor.cs"); [Fact] void should_parse_the_inline_reactor_code() => Slice("DetectOverdueInvoices").Reactors.Single().Triggers.First().Code.ShouldNotBeNull(); diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_with_composed_visitors.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_with_composed_visitors.cs index bd9b639..cdc7f9e 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_with_composed_visitors.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_with_composed_visitors.cs @@ -32,7 +32,7 @@ class projection_visitor : IProjectionSyntaxVisitor class slice_visitor(IProjectionSyntaxVisitor projections) : ISliceSyntaxVisitor { - public string Visit(SliceSyntax syntax) => syntax.Projection is null ? syntax.Name : $"{syntax.Name}[{projections.Visit(syntax.Projection)}]"; + public string Visit(SliceSyntax syntax) => syntax.Projections.FirstOrDefault() is not { } projection ? syntax.Name : $"{syntax.Name}[{projections.Visit(projection)}]"; } class feature_visitor(ISliceSyntaxVisitor slices) : IFeatureSyntaxVisitor diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_the_invoicing_sample.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_the_invoicing_sample.cs index 66bc3b7..a39608b 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_the_invoicing_sample.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_the_invoicing_sample.cs @@ -28,7 +28,7 @@ void Because() [Fact] void should_preserve_the_imports() => _reparsed.Value!.Imports.Count().ShouldEqual(_original.Value!.Imports.Count()); [Fact] void should_preserve_the_concepts() => _reparsed.Value!.Concepts.Count().ShouldEqual(_original.Value!.Concepts.Count()); [Fact] void should_preserve_the_enum_values() => Concept(_reparsed, "InvoiceStatus").Values.Count().ShouldEqual(Concept(_original, "InvoiceStatus").Values.Count()); - [Fact] void should_preserve_the_pii_attribute() => Concept(_reparsed, "EmailAddress").Attributes.ShouldContain("pii"); + [Fact] void should_preserve_the_pii_attribute() => Concept(_reparsed, "EmailAddress").Attributes.Select(_ => _.Name).ShouldContain("pii"); [Fact] void should_preserve_the_policies() => _reparsed.Value!.Policies.Count().ShouldEqual(_original.Value!.Policies.Count()); [Fact] void should_preserve_the_personas() => _reparsed.Value!.Personas!.Count().ShouldEqual(_original.Value!.Personas!.Count()); [Fact] void should_preserve_the_persona_policies() => _reparsed.Value!.Personas!.First().Policies.Count().ShouldEqual(_original.Value!.Personas!.First().Policies.Count()); From aa77e2e5a974f8768e980ef9ec04e7361ed03d08 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 11:30:47 +0200 Subject: [PATCH 2/6] Add specs for the declarative language additions --- ...ng_a_query_with_duplicate_by_parameters.cs | 34 ++++++++++++ .../when_compiling_a_reactor_with_outputs.cs | 51 ++++++++++++++++++ ...ompiling_a_reactor_with_unknown_outputs.cs | 31 +++++++++++ ...when_compiling_a_reactor_without_a_body.cs | 36 +++++++++++++ ...iling_a_slice_with_multiple_projections.cs | 44 +++++++++++++++ .../when_compiling_an_observable_query.cs | 35 ++++++++++++ ...mpiling_concept_attributes_with_reasons.cs | 30 +++++++++++ ..._compiling_conditional_validation_rules.cs | 50 +++++++++++++++++ .../when_printing_a_reactor.cs | 51 ++++++++++++++++++ ...nting_a_slice_with_multiple_projections.cs | 42 +++++++++++++++ ...rinting_concept_attributes_with_reasons.cs | 30 +++++++++++ ...n_printing_conditional_validation_rules.cs | 53 +++++++++++++++++++ 12 files changed, 487 insertions(+) create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_query_with_duplicate_by_parameters.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_outputs.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_unknown_outputs.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_without_a_body.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_slice_with_multiple_projections.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_an_observable_query.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_concept_attributes_with_reasons.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_reactor.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_slice_with_multiple_projections.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_conditional_validation_rules.cs diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_query_with_duplicate_by_parameters.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_query_with_duplicate_by_parameters.cs new file mode 100644 index 0000000..77d66a1 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_query_with_duplicate_by_parameters.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_a_query_with_duplicate_by_parameters : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateView InvoiceList + query GetInvoice => InvoiceDetailsReadModel + by invoiceId InvoiceId + by customerId CustomerId + """; + + CompilationResult _result; + QuerySyntax _query; + + void Because() + { + _result = _compiler.Compile(Source); + _query = _result.Value!.Modules.Single().Features.Single().Slices.Single().Queries.Single(); + } + + [Fact] void should_not_succeed() => _result.Success.ShouldBeFalse(); + [Fact] void should_report_the_duplicate() => _result.Diagnostics.Single().Severity.ShouldEqual(DiagnosticSeverity.Error); + [Fact] void should_point_at_the_second_parameter() => _result.Diagnostics.Single().Location.Line.ShouldEqual(6); + [Fact] void should_keep_the_first_parameter() => _query.By!.Name.ShouldEqual("invoiceId"); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_outputs.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_outputs.cs new file mode 100644 index 0000000..cc6b5cf --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_outputs.cs @@ -0,0 +1,51 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_a_reactor_with_outputs : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange MarkInvoiceOverdue + command MarkInvoiceOverdue + invoiceId Uuid + + event InvoiceRegistered + invoiceId Uuid + + event CustomerNotified + invoiceId Uuid + + event ReminderScheduled + invoiceId Uuid + + slice Automation NotifyCustomer + reactor CustomerNotifier + on InvoiceRegistered + produces CustomerNotified + produces ReminderScheduled + executes MarkInvoiceOverdue + file Reactors/CustomerNotifier.cs + """; + + CompilationResult _result; + ReactorTriggerSyntax _trigger; + + void Because() + { + _result = _compiler.Compile(Source); + _trigger = _result.Value!.Modules.Single().Features.Single().Slices + .Single(_ => _.Name == "NotifyCustomer").Reactors.Single().Triggers.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_declare_both_produced_events() => _trigger.Produces!.Select(_ => _.Event).ShouldContainOnly(["CustomerNotified", "ReminderScheduled"]); + [Fact] void should_declare_the_executed_command() => _trigger.Executes!.Single().Command.ShouldEqual("MarkInvoiceOverdue"); + [Fact] void should_keep_the_file_reference() => _trigger.File!.Path.ShouldEqual("Reactors/CustomerNotifier.cs"); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_unknown_outputs.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_unknown_outputs.cs new file mode 100644 index 0000000..20af4f4 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_with_unknown_outputs.cs @@ -0,0 +1,31 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_a_reactor_with_unknown_outputs : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice Automation NotifyCustomer + event InvoiceRegistered + invoiceId Uuid + + reactor CustomerNotifier + on InvoiceRegistered + produces CustomerNotified + executes MarkInvoiceOverdue + """; + + CompilationResult _result; + + void Because() => _result = _compiler.Compile(Source); + + [Fact] void should_warn_about_the_unknown_event() => _result.Diagnostics.Any(_ => _.Message.Contains("Unknown event 'CustomerNotified'", StringComparison.Ordinal)).ShouldBeTrue(); + [Fact] void should_warn_about_the_unknown_command() => _result.Diagnostics.Any(_ => _.Message.Contains("Unknown command 'MarkInvoiceOverdue'", StringComparison.Ordinal)).ShouldBeTrue(); + [Fact] void should_report_both_references() => _result.Diagnostics.Count().ShouldEqual(2); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_without_a_body.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_without_a_body.cs new file mode 100644 index 0000000..00459f0 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_reactor_without_a_body.cs @@ -0,0 +1,36 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_a_reactor_without_a_body : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice Automation NotifyCustomer + event InvoiceRegistered + invoiceId Uuid + + reactor CustomerNotifier + on InvoiceRegistered + """; + + CompilationResult _result; + ReactorTriggerSyntax _trigger; + + void Because() + { + _result = _compiler.Compile(Source); + _trigger = _result.Value!.Modules.Single().Features.Single().Slices.Single().Reactors.Single().Triggers.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_keep_the_trigger() => _trigger.Event.ShouldEqual("InvoiceRegistered"); + [Fact] void should_not_have_a_file_reference() => _trigger.File.ShouldBeNull(); + [Fact] void should_not_have_inline_code() => _trigger.Code.ShouldBeNull(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_slice_with_multiple_projections.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_slice_with_multiple_projections.cs new file mode 100644 index 0000000..33c7746 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_slice_with_multiple_projections.cs @@ -0,0 +1,44 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_a_slice_with_multiple_projections : given.a_compiler +{ + const string Source = + """ + module CustomerPortal + feature Report + slice StateView CustomerPortalReport + event ReportGenerated + reportId Uuid + + event TokenRevoked + reportId Uuid + + projection PortalReport => PortalReport + from ReportGenerated + reportId = reportId + + projection RevokedCustomerPortalToken => RevokedCustomerPortalToken + from TokenRevoked + reportId = reportId + """; + + CompilationResult _result; + SliceSyntax _slice; + + void Because() + { + _result = _compiler.Compile(Source); + _slice = _result.Value!.Modules.Single().Features.Single().Slices.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_keep_both_projections() => _slice.Projections.Count().ShouldEqual(2); + [Fact] void should_keep_the_view_model() => _slice.Projections.First().ReadModel.ShouldEqual("PortalReport"); + [Fact] void should_keep_the_companion_model() => _slice.Projections.Last().ReadModel.ShouldEqual("RevokedCustomerPortalToken"); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_an_observable_query.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_an_observable_query.cs new file mode 100644 index 0000000..e08295c --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_an_observable_query.cs @@ -0,0 +1,35 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_an_observable_query : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateView InvoiceList + query ListInvoices => InvoiceListReadModel[] + observable + filter status InvoiceStatus? + + query GetInvoice => InvoiceDetailsReadModel + by invoiceId InvoiceId + """; + + 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_mark_the_observable_query() => Query("ListInvoices").IsObservable.ShouldBeTrue(); + [Fact] void should_keep_the_filter() => Query("ListInvoices").Filters.Single().Name.ShouldEqual("status"); + [Fact] void should_leave_an_unmarked_query_one_shot() => Query("GetInvoice").IsObservable.ShouldBeFalse(); + + QuerySyntax Query(string name) => + _result.Value!.Modules.Single().Features.Single().Slices.Single().Queries.Single(_ => _.Name == name); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_concept_attributes_with_reasons.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_concept_attributes_with_reasons.cs new file mode 100644 index 0000000..9e5ba6a --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_concept_attributes_with_reasons.cs @@ -0,0 +1,30 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_concept_attributes_with_reasons : given.a_compiler +{ + const string Source = + """ + concept BankAccountNumber : String @pii("Partner payout account - lawful basis: \"contract performance\".") @sensitive + concept EmailAddress : String @pii + concept CustomerRank : Int @ranked("Ordering hint") + """; + + 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_carry_the_reason() => Attribute("BankAccountNumber", "pii").Value.ShouldEqual("Partner payout account - lawful basis: \"contract performance\"."); + [Fact] void should_keep_an_argumentless_attribute_alongside() => Attribute("BankAccountNumber", "sensitive").Value.ShouldBeNull(); + [Fact] void should_keep_a_bare_attribute_valid() => Attribute("EmailAddress", "pii").Value.ShouldBeNull(); + [Fact] void should_tolerate_an_unknown_attribute_with_an_argument() => Attribute("CustomerRank", "ranked").Value.ShouldEqual("Ordering hint"); + + ConceptAttributeSyntax Attribute(string concept, string name) => + _result.Value!.Concepts.Single(_ => _.Name == concept).Attributes.Single(_ => _.Name == name); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs new file mode 100644 index 0000000..10d707f --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs @@ -0,0 +1,50 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler; + +public class when_compiling_conditional_validation_rules : given.a_compiler +{ + const string Source = + """ + module Engagements + feature Extensions + slice StateChange ExtendEngagement + command ExtendEngagement + startDate Date + endDate Date + isExtension Bool + reason String + + validate + endDate >= startDate + startDate < today + reason not empty when isExtension == true and startDate < today message "A reason is required" + reason matches "^when .+$" + """; + + CompilationResult _result; + IEnumerable _rules; + + void Because() + { + _result = _compiler.Compile(Source); + _rules = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single() + .Validations.OfType().Single().Rules; + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_resolve_a_path_operand_to_a_sibling_property() => ((PathExpressionSyntax)Rule(0).Value!).Path.ShouldEqual("startDate"); + [Fact] void should_resolve_today_to_the_keyword() => Rule(1).Value.ShouldBeOfExactType(); + [Fact] void should_leave_an_unconditional_rule_unconditional() => Rule(0).When.ShouldBeNull(); + [Fact] void should_parse_the_rule_condition() => Rule(2).When.ShouldBeOfExactType(); + [Fact] void should_keep_the_rule_itself() => Rule(2).Rule.ShouldEqual(ValidationRuleKind.NotEmpty); + [Fact] void should_keep_the_message() => Rule(2).Message.ShouldEqual("A reason is required"); + [Fact] void should_not_read_when_inside_a_quoted_operand() => Rule(3).When.ShouldBeNull(); + [Fact] void should_keep_the_quoted_operand() => ((LiteralExpressionSyntax)Rule(3).Value!).Value.ShouldEqual("^when .+$"); + + ValidationRuleSyntax Rule(int index) => _rules.ElementAt(index); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_reactor.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_reactor.cs new file mode 100644 index 0000000..6e10bd1 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_reactor.cs @@ -0,0 +1,51 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayPrinter; + +public class when_printing_a_reactor : given.a_printer +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange MarkInvoiceOverdue + command MarkInvoiceOverdue + invoiceId Uuid + + event InvoiceRegistered + invoiceId Uuid + + event CustomerNotified + invoiceId Uuid + + slice Automation NotifyCustomer + reactor CustomerNotifier + on InvoiceRegistered + produces CustomerNotified + executes MarkInvoiceOverdue + file Reactors/CustomerNotifier.cs + on CustomerNotified + """; + + 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_the_produced_event() => _roundtrip.Printed.ShouldContain("produces CustomerNotified"); + [Fact] void should_print_the_executed_command() => _roundtrip.Printed.ShouldContain("executes MarkInvoiceOverdue"); + [Fact] void should_preserve_the_produced_event() => Trigger(_roundtrip.Reparsed, 0).Produces!.Single().Event.ShouldEqual("CustomerNotified"); + [Fact] void should_preserve_the_executed_command() => Trigger(_roundtrip.Reparsed, 0).Executes!.Single().Command.ShouldEqual("MarkInvoiceOverdue"); + [Fact] void should_preserve_the_file_reference() => Trigger(_roundtrip.Reparsed, 0).File!.Path.ShouldEqual("Reactors/CustomerNotifier.cs"); + [Fact] void should_preserve_the_trigger_without_a_body() => Trigger(_roundtrip.Reparsed, 1).Event.ShouldEqual("CustomerNotified"); + [Fact] void should_keep_the_second_trigger_bodyless() => Trigger(_roundtrip.Reparsed, 1).File.ShouldBeNull(); + + static ReactorTriggerSyntax Trigger(CompilationResult result, int index) => + result.Value!.Modules.Single().Features.Single().Slices + .Single(_ => _.Name == "NotifyCustomer").Reactors.Single().Triggers.ElementAt(index); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_slice_with_multiple_projections.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_slice_with_multiple_projections.cs new file mode 100644 index 0000000..19915bc --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_slice_with_multiple_projections.cs @@ -0,0 +1,42 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayPrinter; + +public class when_printing_a_slice_with_multiple_projections : given.a_printer +{ + const string Source = + """ + module CustomerPortal + feature Report + slice StateView CustomerPortalReport + event ReportGenerated + reportId Uuid + + event TokenRevoked + reportId Uuid + + projection PortalReport => PortalReport + from ReportGenerated + reportId = reportId + + projection RevokedCustomerPortalToken => RevokedCustomerPortalToken + from TokenRevoked + reportId = reportId + """; + + 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_preserve_both_projections() => Slice(_roundtrip.Reparsed).Projections.Count().ShouldEqual(2); + [Fact] void should_preserve_the_read_models() => Slice(_roundtrip.Reparsed).Projections.Select(_ => _.ReadModel).ShouldContainOnly(["PortalReport", "RevokedCustomerPortalToken"]); + + static SliceSyntax Slice(CompilationResult result) => + result.Value!.Modules.Single().Features.Single().Slices.Single(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs new file mode 100644 index 0000000..c6e52c8 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs @@ -0,0 +1,30 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayPrinter; + +public class when_printing_concept_attributes_with_reasons : given.a_printer +{ + const string Source = + """ + concept BankAccountNumber : String @pii("Partner payout account - lawful basis: \"contract performance\".") @sensitive + concept EmailAddress : String @pii + """; + + 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_escape_the_quotes_in_the_reason() => _roundtrip.Printed.ShouldContain("@pii(\"Partner payout account - lawful basis: \\\"contract performance\\\".\")"); + [Fact] void should_print_a_bare_attribute_without_parentheses() => _roundtrip.Printed.ShouldContain("concept EmailAddress : String @pii\n"); + [Fact] void should_preserve_the_reason() => Attribute("BankAccountNumber", "pii").Value.ShouldEqual("Partner payout account - lawful basis: \"contract performance\"."); + [Fact] void should_preserve_the_argumentless_attribute() => Attribute("BankAccountNumber", "sensitive").Value.ShouldBeNull(); + + ConceptAttributeSyntax Attribute(string concept, string name) => + _roundtrip.Reparsed.Value!.Concepts.Single(_ => _.Name == concept).Attributes.Single(_ => _.Name == name); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_conditional_validation_rules.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_conditional_validation_rules.cs new file mode 100644 index 0000000..24b1668 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_conditional_validation_rules.cs @@ -0,0 +1,53 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayPrinter; + +public class when_printing_conditional_validation_rules : given.a_printer +{ + const string Source = + """ + concept ExtensionReason : String + validate + not empty when isExtension == true message "A reason is required" + + module Engagements + feature Extensions + slice StateChange ExtendEngagement + command ExtendEngagement + startDate Date + endDate Date + @today Date + isExtension Bool + + validate + endDate >= startDate + startDate < today + startDate < @today + endDate > startDate when isExtension == true message "An extension has to move the end date out" + """; + + 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_the_condition_before_the_message() => _roundtrip.Printed.ShouldContain("endDate > startDate when isExtension == true message \"An extension has to move the end date out\""); + [Fact] void should_print_the_today_keyword() => _roundtrip.Printed.ShouldContain("startDate < today\n"); + [Fact] void should_escape_a_property_named_today() => _roundtrip.Printed.ShouldContain("startDate < @today"); + [Fact] void should_preserve_the_condition() => Rule(3).When.ShouldNotBeNull(); + [Fact] void should_preserve_the_today_keyword() => Rule(1).Value.ShouldBeOfExactType(); + [Fact] void should_preserve_the_escaped_property() => ((PathExpressionSyntax)Rule(2).Value!).Path.ShouldEqual("today"); + [Fact] void should_preserve_the_concept_rule_condition() => ConceptRule().When.ShouldNotBeNull(); + + ValidationRuleSyntax Rule(int index) => + _roundtrip.Reparsed.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single() + .Validations.OfType().Single().Rules.ElementAt(index); + + ValidationRuleSyntax ConceptRule() => + _roundtrip.Reparsed.Value!.Concepts.Single().Validations!.OfType().Single().Rules.Single(); +} From 46103d2d416e3e67c526cf63029dae466f1c6e08 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 11:30:47 +0200 Subject: [PATCH 3/6] Add the new keywords to the Monaco language service --- Source/Screenplay/Monaco/screenplay-language/language.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/Screenplay/Monaco/screenplay-language/language.ts b/Source/Screenplay/Monaco/screenplay-language/language.ts index 0faf402..dcac19f 100644 --- a/Source/Screenplay/Monaco/screenplay-language/language.ts +++ b/Source/Screenplay/Monaco/screenplay-language/language.ts @@ -46,6 +46,7 @@ export const clauseKeywords = [ 'authorize', 'validate', 'produces', + 'executes', 'handler', 'when', 'given', @@ -64,6 +65,7 @@ export const clauseKeywords = [ 'false', 'and', 'or', + 'observable', 'by', 'filter', 'data', From 89ec60193cd8807fb4a2907e728356c440b5e53b Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 11:30:47 +0200 Subject: [PATCH 4/6] Document the declarative language additions --- Documentation/screenplay/commands.md | 23 ++++++++++++++++ Documentation/screenplay/concepts.md | 16 +++++++++-- Documentation/screenplay/grammar.md | 21 +++++++++----- Documentation/screenplay/queries.md | 16 ++++++++++- Documentation/screenplay/reactors.md | 41 +++++++++++++++++++++++++++- Documentation/screenplay/slices.md | 20 ++++++++++++++ 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/Documentation/screenplay/commands.md b/Documentation/screenplay/commands.md index c4283b7..1790a09 100644 --- a/Documentation/screenplay/commands.md +++ b/Documentation/screenplay/commands.md @@ -87,6 +87,29 @@ validate dueDate > today message "Due date must be in the future" ``` +### Comparing against another property + +An operand does not have to be a constant. A bare path resolves against the **sibling properties of the artifact being validated** - the command's own properties for a command rule - so the rule that matters most in a date range says exactly what it means: + +```screenplay +validate + endDate >= startDate message "The end date cannot be before the start date" +``` + +`today` is a keyword, not a path: it evaluates to the current date when the rule runs. A property genuinely called `today` is written `@today`, using the same [keyword escape](grammar.md#keyword-escape) as everywhere else. + +### Rules that only apply sometimes + +A rule that holds only under a condition takes a `when` clause, using the same condition grammar as `produces when`: + +```screenplay +validate + newEndDate > endDate when isExtension == true message "An extension has to move the end date out" + reason not empty when status == "cancelled" message "Cancelling requires a reason" +``` + +The condition comes after the rule and before the `message`, and it may combine comparisons with `and` and `or`. An unconditional rule is the same as it always was. + Cross-field or complex rules drop into C#: ````screenplay diff --git a/Documentation/screenplay/concepts.md b/Documentation/screenplay/concepts.md index b377a17..1f99dcd 100644 --- a/Documentation/screenplay/concepts.md +++ b/Documentation/screenplay/concepts.md @@ -5,7 +5,7 @@ Concepts are formalized value types that wrap a primitive. They give every domai ## Syntax ```screenplay -concept : [] +concept : [@[("")]]* [validate ...]* concept : Enum @@ -24,11 +24,23 @@ concept : Enum | `@pii` | The value is personally identifiable information. Chronicle manages it and can erase it for GDPR compliance. | | `@sensitive` | The value is sensitive and handled under Chronicle's sensitivity rules. | +Any `@word` is accepted, so a consumer can introduce an attribute the compiler does not know about without a grammar change. + +### Carry the reason, not just the flag + +A compliance reader wants to know *why* a value is personal data - the purpose, the lawful basis, whose subject it lives under. Give the attribute a quoted argument and the document carries that reasoning instead of losing it: + +```screenplay +concept BankAccountNumber : String @pii("Partner payout bank account - financial data. Remits self-billing payments; lawful basis: contract performance / legal obligation.") +``` + +The argument is optional on every attribute - a bare `@pii` stays exactly as valid as it has always been. + ## Examples ```screenplay concept InvoiceId : Uuid -concept EmailAddress : String @pii +concept EmailAddress : String @pii("Contact address for invoice delivery. Lawful basis: contract performance.") concept NationalIdNumber : String @pii @sensitive concept DateOfBirth : Date @pii ``` diff --git a/Documentation/screenplay/grammar.md b/Documentation/screenplay/grammar.md index 281a354..5e2aeec 100644 --- a/Documentation/screenplay/grammar.md +++ b/Documentation/screenplay/grammar.md @@ -40,7 +40,7 @@ ConceptRule = RuleOp, [ "message", LocalizableString ], NL ; PrimitiveType = "Uuid" | "String" | "Int" | "Decimal" | "Bool" | "Date" | "DateTime" ; -Attribute = "@pii" | "@sensitive" ; +Attribute = "@", Ident, [ "(", StringLiteral, ")" ] ; (* -------------------------------------------------------------- *) (* Policies *) @@ -183,7 +183,7 @@ ValidateDecl = "validate", NL, INDENT, { ValidationRule }, DEDENT | "validate", "csharp", NL, InlineBlock ; -ValidationRule = Ident, RuleOp, [ "message", LocalizableString ], NL ; +ValidationRule = Ident, RuleOp, [ "when", Condition ], [ "message", LocalizableString ], NL ; RuleOp = "not empty" | "max", Number @@ -198,7 +198,9 @@ RuleOp = "not empty" | "all", ">", Value | "all", ">=", Value ; -Value = Number | StringLiteral | "today" | "true" | "false" ; +Value = Number | StringLiteral | "today" | "true" | "false" | PropertyPath ; + +PropertyPath = [ "@" ], Ident, { ".", Ident } ; (* -------------------------------------------------------------- *) (* Produces *) @@ -247,6 +249,7 @@ HandlerDecl = "handler", NL, QueryDecl = "query", Ident, "=>", TypeRef, NL, [ INDENT, + [ "observable", NL ], [ ByClause ], { FilterClause }, [ AuthorizeDecl ], @@ -335,10 +338,14 @@ ConstraintBody = "unique", Ident, "on", Ident, NL (* unique property *) (* -------------------------------------------------------------- *) ReactorDecl = "reactor", Ident, NL, - INDENT, - "on", Ident, NL, - ( FileDirective | InlineBlock ), - DEDENT ; + INDENT, { ReactorTrigger }, DEDENT ; + +ReactorTrigger = "on", Ident, NL, + [ INDENT, + { "produces", Ident, NL }, + { "executes", Ident, NL }, + [ FileDirective | InlineBlock ], + DEDENT ] ; (* -------------------------------------------------------------- *) (* Screens *) diff --git a/Documentation/screenplay/queries.md b/Documentation/screenplay/queries.md index 94eba77..ab10223 100644 --- a/Documentation/screenplay/queries.md +++ b/Documentation/screenplay/queries.md @@ -6,6 +6,7 @@ Queries are read-side entry points. A query maps to a return type — a read mod ```screenplay query => [[]?] + [observable] [by ] [filter ?] [authorize [or ]*] @@ -13,10 +14,23 @@ query => [[]?] | Clause | Meaning | | --- | --- | -| `by` | The identifying parameter — the query returns the instance it identifies. | +| `observable` | The query is live — a subscription that pushes updates rather than a one-shot read. | +| `by` | The identifying parameter — the query returns the instance it identifies. A query declares at most one. | | `filter` | An optional parameter narrowing the result set. Filter types are typically optional (`?`). | | `authorize` | The [policies](policies.md) that must pass. | +## Live or one-shot + +Whether a query pushes updates changes what the reader has to build: a live query holds a subscription and re-renders as events land, a one-shot query answers once. Say which it is: + +```screenplay +query ListInvoices => InvoiceListReadModel[] + observable + filter status InvoiceStatus? +``` + +An unmarked query is one-shot, so every document written before this marker existed keeps its meaning. + ## Examples A single-instance query identified by a parameter: diff --git a/Documentation/screenplay/reactors.md b/Documentation/screenplay/reactors.md index 83c682f..093f427 100644 --- a/Documentation/screenplay/reactors.md +++ b/Documentation/screenplay/reactors.md @@ -7,6 +7,8 @@ Reactors are event reaction rules — the "if this then that" of a Screenplay. T ```screenplay reactor on + [produces ] + [executes ] [file ] [csharp ``` @@ -14,7 +16,44 @@ reactor ```] ``` -Each `on` clause names the event that triggers the reaction. The reaction body is either a `file` reference to an external C# implementation, or an inline `csharp` block. Inside the block, `@event` is the triggering event; returned events are appended as side effects. +Each `on` clause names the event that triggers the reaction. Everything under it is optional: what the reaction produces, and where its implementation lives - a `file` reference to an external C# implementation, or an inline `csharp` block. Inside the block, `@event` is the triggering event; returned events are appended as side effects. + +## Say what the reaction does + +`on` tells the reader what wakes the reactor up. `produces` and `executes` tell them what happens next - the output side of an automation slice, which is the whole reason the slice exists: + +```screenplay +reactor AcceptedInvitationProvisioner + on InvitationToJoinAccepted + produces UserAccountProvisioned + +reactor StockKeeping + on BookReserved + executes DecreaseStock +``` + +Both are repeatable, so one trigger can produce several events and execute several commands. `produces` names an event declared in the document (or imported); `executes` names a command. The compiler resolves both and warns about a name it cannot find, the same way `command produces` has always been checked. + +Together with `on`, this closes the Event Modeling loop the document is meant to describe: state change → events → projections → automation → commands → state change. + +## Declare the intent before the code exists + +You write the document first and Stage performs it, so a reactor has to be sayable before anything implements it. A trigger with no body is a complete, valid statement of intent: + +```screenplay +reactor AcceptedInvitationProvisioner + on InvitationToJoinAccepted +``` + +That reads as "when an invitation is accepted, this reactor runs" - which is what an author knows at modeling time. The `file` line arrives later, when the slice is implemented, and it never changes what the reactor *means*: + +```screenplay +reactor AcceptedInvitationProvisioner + on InvitationToJoinAccepted + file Admin/Invitations/Provision/Provision.cs +``` + +This is the general rule across the language: **a document must be expressible and meaningful with zero `file` references.** `file` is attachable realization metadata on any construct that supports it. Hand-authored documents precede the code and gain `file` lines as slices are implemented; generated documents arrive with them already attached. Same language, two directions. ## Examples diff --git a/Documentation/screenplay/slices.md b/Documentation/screenplay/slices.md index a298f75..221f60c 100644 --- a/Documentation/screenplay/slices.md +++ b/Documentation/screenplay/slices.md @@ -83,6 +83,26 @@ module Invoicing | `reactor` | `Automation` | [Reactors](reactors.md) | | `capture` | `Translate` | [Captures](captures.md) | +Every construct is repeatable - a slice declares as many events, commands, queries, projections, screens, reactors and captures as the behavior needs. + +### More than one projection + +A slice often needs a second read model that nobody outside the slice ever queries: the state its own command reads to make a decision, or the companion model that guards access to the first one. Each `projection` names the read model it builds, so there is nothing ambiguous about declaring several: + +```screenplay +slice StateView CustomerPortalReport + query Report => PortalReport + ... + + projection PortalReport => PortalReport + ... + + projection RevokedCustomerPortalToken => RevokedCustomerPortalToken + ... +``` + +Keeping the decision model beside the view model is how the slice stays the unit that changes together - splitting it into an artificial second slice describes an application that does not exist. + ## Example ```screenplay From de11c8f52c7644ed1cf0a60d48c0892f1688ae7c Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 11:44:53 +0200 Subject: [PATCH 5/6] Offer the new keywords in completion and hover The keywords were added to the tokenizer but not to the completion lists or the hover documentation, so 'observable' inside a query body and 'produces' / 'executes' inside a reactor trigger highlighted correctly while the editor still refused to suggest or explain them. --- .../Screenplay/Monaco/screenplay-language/completion-items.ts | 3 +++ Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/Screenplay/Monaco/screenplay-language/completion-items.ts b/Source/Screenplay/Monaco/screenplay-language/completion-items.ts index a84a415..6e772d8 100644 --- a/Source/Screenplay/Monaco/screenplay-language/completion-items.ts +++ b/Source/Screenplay/Monaco/screenplay-language/completion-items.ts @@ -60,6 +60,7 @@ export const handlerItems: CompletionEntry[] = [ ]; export const queryItems: CompletionEntry[] = [ + { label: 'observable', insertText: 'observable', documentation: 'The query is live — a subscription that pushes updates rather than a one-shot read.' }, { label: 'by', insertText: 'by ${1:param} ${2:Type}', documentation: 'Declares the identifying parameter of the query.' }, { label: 'filter', insertText: 'filter ${1:param} ${2:Type}?', documentation: 'Declares an optional filter parameter.' }, { label: 'authorize', insertText: 'authorize ${1:PolicyName}', documentation: 'References the policies that must pass for the query to execute.' }, @@ -76,6 +77,8 @@ export const reactorItems: CompletionEntry[] = [ ]; export const reactorOnItems: CompletionEntry[] = [ + { label: 'produces', insertText: 'produces ${1:EventType}', documentation: 'Declares an event the reaction appends as a side effect.' }, + { label: 'executes', insertText: 'executes ${1:Command}', documentation: 'Declares a command the reaction puts through the command pipeline.' }, { label: 'file', insertText: 'file ${1:Path}', documentation: 'Delegates the reaction to an external C# file.' }, { label: 'csharp', insertText: fenced('csharp'), documentation: 'Inline C# returning event side effects.' }, ]; diff --git a/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts b/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts index 0c3db87..8184164 100644 --- a/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts +++ b/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts @@ -25,7 +25,8 @@ export const keywordDocs: Record = { constraint: 'A server-side rule enforced in the Chronicle kernel before events are committed.', authorize: 'References the policies that must all pass for the construct to execute.', validate: 'Declarative validation rules, or `validate csharp` for imperative rules — on commands and concepts.', - produces: 'Declares the events a command emits — single, multiple, or conditional.', + produces: 'Declares the events a command emits — single, multiple, or conditional — or an event a reactor trigger appends as a side effect.', + executes: 'Declares a command a reactor trigger puts through the command pipeline.', handler: 'A fully imperative command implementation — a `file ` reference or an inline `csharp` block, instead of `produces`.', when: 'Guards a produced event or a capture append with a condition.', require: 'A policy condition: `authenticated`, `role "..."`, or `claim "..." matches ...`.', @@ -54,6 +55,7 @@ export const keywordDocs: Record = { for: 'Introduces a seed group — the events to seed for one event source id.', readmodel: 'Read model state in a specification — `given readmodel` establishes it, `then readmodel` asserts it.', file: 'Delegates the implementation to an external file; the Screenplay contract stays visible.', + observable: 'Marks a query as live — a subscription that pushes updates. An unmarked query is one-shot.', by: 'Introduces the identifying parameter.', via: 'Connects screen data to the query that supplies it.', filter: 'An optional query filter parameter.', From 0fdb66d091c25575fa31960cb7f00ea86aeefbbe Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 12:31:49 +0200 Subject: [PATCH 6/6] Accept the keyword escape on a validation rule subject '@today' worked as a rule operand but not as the rule's subject, so a reader who learned the escape in one position hit an error using it in the other. No validate-block directive collides with a property name, so the escape is not strictly needed there - but it costs nothing to accept and removes the inconsistency. Also covers attribute reasons holding characters that matter to the grammar - a close paren, an at sign, a literal backslash - and two attributes that both carry an argument, none of which the round-trip specs exercised. --- Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs | 4 ++-- .../when_compiling_conditional_validation_rules.cs | 3 +++ .../when_printing_concept_attributes_with_reasons.cs | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs index 3756ea3..34a9390 100644 --- a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs @@ -29,7 +29,7 @@ internal static partial class ValidationRuleParser return null; } - var property = match.Groups[1].Value; + var property = LineText.Unescape(match.Groups[1].Value); var rule = match.Groups[2].Value; var (kind, value) = ParseRule(context, rule, line); if (kind is null) @@ -164,7 +164,7 @@ internal static partial class ValidationRuleParser [GeneratedRegex("\\bmessage\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex MessageRegex(); - [GeneratedRegex(@"^([\w.]+)\s+(.+)$", RegexOptions.None, 1000)] + [GeneratedRegex(@"^(@?[\w.]+)\s+(.+)$", RegexOptions.None, 1000)] private static partial Regex RuleRegex(); [GeneratedRegex(@"^(not empty|length ==|all >=|all >|matches|max|min|>=|<=|==|>|<)\s*(.*)$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs index 10d707f..5211b08 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_conditional_validation_rules.cs @@ -17,12 +17,14 @@ startDate Date endDate Date isExtension Bool reason String + @today Date validate endDate >= startDate startDate < today reason not empty when isExtension == true and startDate < today message "A reason is required" reason matches "^when .+$" + @today not empty """; CompilationResult _result; @@ -45,6 +47,7 @@ void Because() [Fact] void should_keep_the_message() => Rule(2).Message.ShouldEqual("A reason is required"); [Fact] void should_not_read_when_inside_a_quoted_operand() => Rule(3).When.ShouldBeNull(); [Fact] void should_keep_the_quoted_operand() => ((LiteralExpressionSyntax)Rule(3).Value!).Value.ShouldEqual("^when .+$"); + [Fact] void should_accept_the_escape_on_a_rule_subject() => Rule(4).Property.ShouldEqual("today"); ValidationRuleSyntax Rule(int index) => _rules.ElementAt(index); } diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs index c6e52c8..d4e957c 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_concept_attributes_with_reasons.cs @@ -11,6 +11,8 @@ public class when_printing_concept_attributes_with_reasons : given.a_printer """ concept BankAccountNumber : String @pii("Partner payout account - lawful basis: \"contract performance\".") @sensitive concept EmailAddress : String @pii + concept Classified : String @pii("first") @sensitive("second") + concept Awkward : String @pii("a close paren ) an at @sensitive and a backslash \\ inside") """; given.a_printer.RoundTripResult _roundtrip; @@ -24,6 +26,9 @@ public class when_printing_concept_attributes_with_reasons : given.a_printer [Fact] void should_print_a_bare_attribute_without_parentheses() => _roundtrip.Printed.ShouldContain("concept EmailAddress : String @pii\n"); [Fact] void should_preserve_the_reason() => Attribute("BankAccountNumber", "pii").Value.ShouldEqual("Partner payout account - lawful basis: \"contract performance\"."); [Fact] void should_preserve_the_argumentless_attribute() => Attribute("BankAccountNumber", "sensitive").Value.ShouldBeNull(); + [Fact] void should_preserve_the_first_of_two_arguments() => Attribute("Classified", "pii").Value.ShouldEqual("first"); + [Fact] void should_preserve_the_second_of_two_arguments() => Attribute("Classified", "sensitive").Value.ShouldEqual("second"); + [Fact] void should_preserve_a_reason_holding_grammar_characters() => Attribute("Awkward", "pii").Value.ShouldEqual(@"a close paren ) an at @sensitive and a backslash \ inside"); ConceptAttributeSyntax Attribute(string concept, string name) => _roundtrip.Reparsed.Value!.Concepts.Single(_ => _.Name == concept).Attributes.Single(_ => _.Name == name);