diff --git a/Documentation/screenplay/action-subjects.md b/Documentation/screenplay/action-subjects.md
new file mode 100644
index 000000000..5a0084e79
--- /dev/null
+++ b/Documentation/screenplay/action-subjects.md
@@ -0,0 +1,63 @@
+---
+title: "Action subjects"
+description: "Explicit action scope, identifier-aware inputs, and the authored/effective syntax boundary."
+---
+
+# Action subjects
+
+State intent explicitly (decision 0110):
+
+```play
+action RetryItem for item label "Retry"
+ with itemId from item.rowId
+ with note from $context.note
+
+action AddItem for screen
+
+toolbar actions
+ item retry action RetryItem for item
+ item add action AddItem for screen
+```
+
+The clause follows the command or guarded label, before an inline `label`. Only action toolbar items accept it; navigation and dialog items do not. These are contextual words, not new reserved declaration or property names. Plain-action `with from ` entries remain ordered authored syntax. Guarded alternatives retain their arguments; toolbar items retain their UI-binding parameters.
+
+## Inference and inputs
+
+`for item` needs one nearest `data` declaration: a record, or a selected collection item. Local sibling data takes precedence over inherited data regardless of source order, including sections, filled slots and outlets. Missing or equally near data is not guessed.
+
+1. Explicit input bindings win.
+2. Remaining non-generated inputs use compatible, case-sensitive same-name fields.
+3. A required, non-generated, non-collection identifier can use one key field with the same declared nominal concept.
+4. Remaining inputs can use an existing command form or renderer input.
+
+Primitive equality, coincidentally similar names, optional identifiers and unmarked foreign keys are not identity proof. Multiple matching keys warn and need an explicit binding. There is no first-row or first-key fallback.
+
+`for screen` keeps plain and toolbar actions independent of selection and does not fill inputs from a row. A guarded action can inspect a single record even with `for screen`; collection-valued data warns, stays unresolved, and never chooses the first row.
+
+Without a clause, a plain or toolbar action infers Item only for exactly one collection data context, exactly one required non-generated identifier, and one matching declared key. Otherwise Screen remains the omitted legacy default. Guarded actions retain their existing single-subject behavior.
+
+## Diagnostics and migration
+
+| Code | Severity | Meaning |
+|---|---|---|
+| PLAY0817 | Error | Malformed, duplicate or misplaced subject clause. |
+| PLAY0818 | Information | Explains an omitted plain/toolbar subject's inference. |
+| PLAY0819 | Warning | Effective row binding differs from legacy case-insensitive name matching. |
+| PLAY0820 | Warning | Multiple nominal keys could bind an input; write an explicit binding. |
+| PLAY0821 | Warning | Guarded `for screen` has collection-valued data and cannot choose one item. |
+
+PLAY0319 still covers malformed/duplicate input syntax, PLAY0345 still covers unknown subject fields, and PLAY0346 still covers a missing or ambiguous guarded subject. Fixes can make inference explicit or retain legacy row behavior using explicit bindings when declaration proof is unambiguous. No fix silently rewrites source or guesses an imported declaration.
+
+## Authored and effective syntax
+
+Native `ScreenActionSubjects.Resolve(application)` and TypeScript `resolveActionSubjects(application)` return a separate effective application and diagnostics without mutating authored syntax. Resolution is idempotent. Consumers needing subject behavior opt in; raw compilation, canonical JSON and printing preserve the source form. Empty inputs and omitted subjects remain absent in legacy raw JSON. The additive native properties do not alter positional record constructors or deconstruction arity.
+
+The paired native/TypeScript fixture packs are under `Source/Screenplay/Compiler/Conformance/action-subjects`: authored source, raw syntax, effective syntax, diagnostics, and context/location sidecars. Each context includes the complete authored declaration tree and canonical-path source locations, so consumers can verify nominal identity without inventing it. Native/TS packs preserve their existing modeled syntax surfaces and assert common-member parity separately.
+
+Runtime execution and rendering proof are Scene-owned. Compiler fixtures and editor assistance do not claim renderer support or change the executable semantic model.
+
+## Corpus migration gate
+
+Before making inference part of a Scene release, compile each real application workspace with its imports and collect PLAY0818 and PLAY0819 by source path, line and command. Treat an unresolvable declaration as insufficient evidence, not a successful migration. For every PLAY0819, review the previous row-name bindings alongside the proposed effective inputs; either state `for screen`, state `for item` with the intended explicit inputs, or retain the previous bindings explicitly. Apply a proposed repair transactionally and compile the complete workspace again. Do not bulk-accept a binding based only on its spelling.
+
+Track that real-corpus scan and disposition review as a follow-up to Screenplay#1052, before the consuming Scene release. The repository samples and paired fixture packs are the compiler regression gate, not evidence that an external corpus has been migrated. Scene must separately verify selection, form/renderer inputs and guarded single-record behavior against the effective pack; it must not reinterpret the raw authored pack as resolved execution metadata.
diff --git a/Documentation/screenplay/ast-compatibility.md b/Documentation/screenplay/ast-compatibility.md
index de5b8c8c1..38b3855ef 100644
--- a/Documentation/screenplay/ast-compatibility.md
+++ b/Documentation/screenplay/ast-compatibility.md
@@ -9,6 +9,18 @@ The syntax tree is a public API. Every consumer that compiles Screenplay to some
This page states those promises. It is deliberately narrow: a guarantee you cannot rely on is worse than one that was never made.
+## Action subject additions
+
+Decision 0110 adds nullable init-only `Subject` properties to `ScreenActionSyntax`,
+`ScreenGuardedActionSyntax` and `ToolbarItemSyntax`, plus ordered init-only `Inputs`
+on plain actions. Positional constructors and deconstruction arities are unchanged.
+TypeScript adds optional equivalents. JSON omits absent subjects and empty inputs;
+unknown subject enum values and subjects on non-action toolbar items are rejected.
+Raw compile/parse and printing preserve authored syntax. Effective semantics require
+native `ScreenActionSubjects.Resolve(application)` or TypeScript
+`resolveActionSubjects(application)`; neither mutates its input. See
+[action subjects](action-subjects.md) for the paired fixture/context boundary.
+
## Clock operand authoring additions
Decisions 0106/0107 add optional `ApplicationSyntax.TimeZone`, `CommandSyntax.Calendar`,
diff --git a/Documentation/screenplay/diagnostics.md b/Documentation/screenplay/diagnostics.md
index 58fb516d8..924182167 100644
--- a/Documentation/screenplay/diagnostics.md
+++ b/Documentation/screenplay/diagnostics.md
@@ -9,6 +9,20 @@ Every problem the compiler finds is reported as a **diagnostic**: a severity, a
A message is written for a person and gets reworded whenever a clearer wording is found. A **code never changes**. So a code is what you match on, suppress on, and group by - anything reading the message text breaks the next time the message is improved.
+## Action subject diagnostics
+
+Decision 0110 adds the following catalog entries:
+
+| Code | Severity | Meaning |
+|---|---|---|
+| `PLAY0817` | Error | An action subject clause is malformed, duplicated, or attached to a non-action toolbar item. Use one `for item` or `for screen` before the inline label. |
+| `PLAY0818` | Information | An omitted plain/toolbar action subject is inferred from its declared data and identifier types. |
+| `PLAY0819` | Warning | Effective row binding differs from legacy case-insensitive name matching. Make intent explicit; a safe repair may retain legacy mappings. |
+| `PLAY0820` | Warning | Several declared nominal keys could fill an input. Write an explicit binding. |
+| `PLAY0821` | Warning | A guarded action `for screen` cannot choose an item from collection-valued data. |
+ See [action subjects](action-subjects.md#diagnostics-and-migration)
+for triggers and safe fixes. PLAY0319, PLAY0345 and PLAY0346 keep their existing meanings.
+
## Reading a diagnostic
The CLI prints a diagnostic in the format editors and build servers already parse - the file, the position, the severity, the code, and the message - followed by the offending line and a caret:
diff --git a/Documentation/screenplay/grammar.md b/Documentation/screenplay/grammar.md
index b0090cb44..56c903f1c 100644
--- a/Documentation/screenplay/grammar.md
+++ b/Documentation/screenplay/grammar.md
@@ -1557,7 +1557,12 @@ ScreenDirective = DataDecl
ToolbarDecl = "toolbar", Ident, NL,
[ INDENT, { ToolbarItem | ToolbarButton }, DEDENT ] ;
-ToolbarItem = "item", Ident, ( "action" | "dialog" ), QualifiedName, NL,
+ToolbarItem = "item", Ident, "action", QualifiedName, [ ActionSubject ], [ "label", LocalizableString ], NL,
+ [ INDENT, { "parameter", Ident, "from", (* UI binding *) NL
+ | "label", LocalizableString, NL
+ | "icon", ( Ident | String ), NL
+ | ToolbarConfirm }, DEDENT ]
+ | "item", Ident, "dialog", QualifiedName, NL,
[ INDENT, { "label", LocalizableString, NL
| "icon", ( Ident | String ), NL
| ToolbarConfirm }, DEDENT ]
@@ -1574,9 +1579,11 @@ ToolbarConfirm = "confirm", LocalizableString, NL ; (* asked before the comman
DataDecl = "data", RequiredTypeRef, "via", "query", QualifiedName,
[ "by", Ident ], NL ;
-ActionDecl = "action", QualifiedName, NL,
+ActionSubject = "for", ( "item" | "screen" ) ;
+
+ActionDecl = "action", QualifiedName, [ ActionSubject ], [ "label", LocalizableString ], NL,
[ INDENT, { ActionOption }, DEDENT ]
- | "action", LocalizableString, NL,
+ | "action", LocalizableString, [ ActionSubject ], NL,
INDENT, { ActionAlternative | ActionOtherwise | NavigateDecl }, DEDENT ;
ActionAlternative = "when", Condition, "execute", QualifiedName, NL,
@@ -1590,6 +1597,7 @@ ActionOtherwise = "otherwise", "hidden", NL
ordering requires numbers, contains / starts with require strings. *)
ActionOption = NavigateDecl
+ | InteractionArgument
| "label", LocalizableString, NL ;
NavigateDecl = "navigate", "to", QualifiedName, [ "by", Ident ], NL,
diff --git a/Documentation/screenplay/screens.md b/Documentation/screenplay/screens.md
index a26133cb9..0bdf4a614 100644
--- a/Documentation/screenplay/screens.md
+++ b/Documentation/screenplay/screens.md
@@ -56,6 +56,10 @@ issues the command and discovers its command-bound form. An action's `navigate t
Screen `data ... via query ... by value` and `navigate ... by value` remain scalar lookups. They cannot supply a composite read-model key to a single-instance query. The compiler reports `PLAY0661`; no automatic screen repair is offered. Collection queries may filter by a subset. See [Read-model keys](readmodels.md#keys).
+## Action subjects
+
+Plain actions, guarded actions and action toolbar items accept `for item` or `for screen` after the command or label, before an inline `label`. Plain actions also accept ordered `with from ` children. Prefer explicit scope in new documents. See [action subjects](action-subjects.md) for inference, nominal identifier/key binding, migration diagnostics and the separate authored/effective compiler boundary.
+
## One action, several commands
Use a label-headed **guarded action** when one user decision can run different commands as the displayed item's state changes. The label is a quoted string or a `$strings.` reference; it replaces the plain action's `label` child.
@@ -77,7 +81,7 @@ screen PlanDetails
### Subject and conditions
-`item` is the nearest container's `data` item, or the selected element when that data is a collection. Sibling data in the action's section, filled slot or screen body takes precedence over outer data, regardless of source order. Without an item (loading or no selection), the action is always hidden, even with an execute fallback. Missing or equally near data declarations produce a warning rather than guessing a subject.
+For a guarded action (omitted subject or `for item`), `item` is the nearest container's `data` item, or the selected element when that data is a collection. Sibling data in the action's section, filled slot or screen body takes precedence over outer data, regardless of source order. Without an item (loading or no selection), the action is always hidden, even with an execute fallback. Missing or equally near data declarations produce a warning rather than guessing a subject.
Conditions compare `item.[.…]` with a literal: a string, number, Boolean or `null`. They do not read route parameters, screen state, `$context` or `$env`, and cannot compare two item paths. Use `==`, `!=`, numeric `>`, `>=`, `<`, `<=`, or string `contains` and `starts with`. Combine comparisons with `and`, `or` and parentheses; `and` binds more tightly. Collection-valued fields are not supported in conditions. Unsupported punctuation, such as brackets around a literal, is rejected with `PLAY0344` rather than discarded.
@@ -92,9 +96,10 @@ Alternatives are checked in authored order whenever query data changes. The firs
Each alternative and execute fallback may bind inputs with `with from `. A binding may pass a terminal collection field, such as `with ids from item.ids`, to a matching collection input; it cannot traverse through a collection. Inputs resolve in this order:
1. Explicit `with` bindings.
-2. Subject fields with the same name as the command input.
-3. The command's declared [form](forms.md).
-4. Renderer input.
+2. Compatible subject fields with the same case-sensitive name as the command input.
+3. A unique declared nominal identifier/key match for required, non-generated identifier inputs.
+4. The command's declared [form](forms.md).
+5. Renderer input.
The guard controls what the user is offered, not what the system accepts. The selected command still enforces its authorization, validation and constraints. If the selected command is unavailable to the caller, present the action as unavailable, as [#335](https://github.com/Cratis/Screenplay/issues/335) defines once admitted. Authorization denial never falls through to another command. A click runs the choice shown to the user; if a click-time check changes that choice, the renderer must refresh instead of executing the new one. A single optional `navigate to` runs after whichever command succeeds, never to open that command's own input screen. If input needs a separate screen or dialog, open it first with a click or row-click carrying the item's identity; place the guarded action on that input screen.
diff --git a/Documentation/screenplay/toc.yml b/Documentation/screenplay/toc.yml
index d5fc759da..04871314c 100644
--- a/Documentation/screenplay/toc.yml
+++ b/Documentation/screenplay/toc.yml
@@ -77,6 +77,8 @@
href: triggers.md
- name: Screens
href: screens.md
+- name: Action subjects
+ href: action-subjects.md
- name: Interactions
href: interactions.md
- name: Layouts and templates
diff --git a/Samples/AcmeCommerce/Purchasing/model/suppliers.play b/Samples/AcmeCommerce/Purchasing/model/suppliers.play
index 54e78d70c..bdf3c10be 100644
--- a/Samples/AcmeCommerce/Purchasing/model/suppliers.play
+++ b/Samples/AcmeCommerce/Purchasing/model/suppliers.play
@@ -55,7 +55,7 @@ module Buying
list
toolbar actions
button register title "Register supplier" slice Buying.Suppliers.RegisterSupplier emphasis primary icon plus
- action OfferSupplierProduct
+ action OfferSupplierProduct for item
label "Offer product"
data SupplierRow[] via query AllSuppliers
table SupplierRow
diff --git a/Samples/AcmeCommerce/Warehouse/model/dispatch.play b/Samples/AcmeCommerce/Warehouse/model/dispatch.play
index a6836e336..4adb41023 100644
--- a/Samples/AcmeCommerce/Warehouse/model/dispatch.play
+++ b/Samples/AcmeCommerce/Warehouse/model/dispatch.play
@@ -235,7 +235,7 @@ module Stock
property title = "Shipments"
property subtitle = "Every parcel with its carrier and tracking number"
toolbar
- action DispatchShipment
+ action DispatchShipment for item
label "Dispatch"
// The stage of the selected parcel decides what can be done to it; the Picking board only shows the stages.
data ShipmentRow[] via query AllShipments
diff --git a/Samples/Library/library.play b/Samples/Library/library.play
index acd893165..f05e18be3 100644
--- a/Samples/Library/library.play
+++ b/Samples/Library/library.play
@@ -174,8 +174,9 @@ module Lending
column title label "Title"
column author label "Author"
column available label "On the shelf"
- action AddBook
- action BorrowBook
+ action AddBook for screen
+ action BorrowBook for item
+ with bookId from item.bookId
specification BorrowingTakesABookOffTheShelf
given caller
@@ -458,7 +459,8 @@ module Lending
column bookId label "Book"
column dueDate label "Due"
column status label "Status"
- action ReturnBook
+ action ReturnBook for item
+ with bookId from item.bookId
specification SeeingAReturnedLoan
given caller
diff --git a/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/ActionSubjectAdvisories.cs b/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/ActionSubjectAdvisories.cs
new file mode 100644
index 000000000..042573483
--- /dev/null
+++ b/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/ActionSubjectAdvisories.cs
@@ -0,0 +1,73 @@
+// 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;
+
+namespace Cratis.Screenplay.CanonicalVectors.Specs.for_ScreenCompositionCorpus;
+
+///
+/// Separates the 0110 action subject advisories (PLAY0818/PLAY0819) from the frozen published corpus diagnostics.
+///
+static class ActionSubjectAdvisories
+{
+ ///
+ /// Determines whether a diagnostic is an action subject advisory the compiler reports but the frozen corpus does not pin.
+ ///
+ /// The diagnostic to inspect.
+ /// True when the diagnostic is an inferred or changed action subject advisory.
+ public static bool IsAdvisory(Diagnostic diagnostic) =>
+ string.Equals(diagnostic.Code, DiagnosticCodes.InferredActionSubject, StringComparison.Ordinal) ||
+ string.Equals(diagnostic.Code, DiagnosticCodes.ChangedActionSubject, StringComparison.Ordinal);
+
+ ///
+ /// Gets the diagnostics that belong to the frozen corpus boundary.
+ ///
+ /// The compile diagnostics.
+ /// The diagnostics without action subject advisories.
+ public static Diagnostic[] WithoutAdvisories(IEnumerable diagnostics) => [.. diagnostics.Where(diagnostic => !IsAdvisory(diagnostic))];
+
+ ///
+ /// Gets the action subject advisories as ordinally sorted "code: message" lines.
+ ///
+ /// The compile diagnostics.
+ /// The sorted advisory lines.
+ public static string[] Describe(IEnumerable diagnostics) => [.. diagnostics.Where(IsAdvisory).Select(diagnostic => $"{diagnostic.Code}: {diagnostic.Message}").Order(StringComparer.Ordinal)];
+
+ ///
+ /// Gets the action subject advisories as ordinally sorted "code@line action" lines.
+ ///
+ /// The compile diagnostics.
+ /// The sorted advisory locations.
+ public static string[] DescribeLocations(IEnumerable diagnostics) => [.. diagnostics.Where(IsAdvisory).Select(diagnostic => $"{diagnostic.Code}@{diagnostic.Location.Line} {ActionName(diagnostic)}").Order(StringComparer.Ordinal)];
+
+ ///
+ /// Gets the source line an advisory points at.
+ ///
+ /// The advisory.
+ /// The documents of the compiled source form, as display path, stable key and text.
+ /// The source line, or null when the location does not resolve to a document line.
+ public static string? SourceLineOf(Diagnostic diagnostic, IReadOnlyList<(string DisplayPath, string StableKey, string Text)> documents)
+ {
+ var document = documents.Count == 1 && diagnostic.Location.Path is null
+ ? documents[0]
+ : documents.FirstOrDefault(candidate => string.Equals(candidate.DisplayPath, diagnostic.Location.Path, StringComparison.Ordinal) || string.Equals(candidate.StableKey, diagnostic.Location.Path, StringComparison.Ordinal));
+ if (document.Text is null)
+ {
+ return null;
+ }
+
+ var lines = document.Text.Split('\n');
+ return diagnostic.Location.Line >= 1 && diagnostic.Location.Line <= lines.Length ? lines[diagnostic.Location.Line - 1] : null;
+ }
+
+ ///
+ /// Gets the action an advisory names.
+ ///
+ /// The advisory.
+ /// The action name quoted first in the message.
+ public static string ActionName(Diagnostic diagnostic)
+ {
+ var start = diagnostic.Message.IndexOf('\'', StringComparison.Ordinal) + 1;
+ return diagnostic.Message[start..diagnostic.Message.IndexOf('\'', start)];
+ }
+}
diff --git a/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_compiling_the_v1_screen_composition_vectors.cs b/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_compiling_the_v1_screen_composition_vectors.cs
index 6cd417c0f..c9c0e9e9a 100644
--- a/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_compiling_the_v1_screen_composition_vectors.cs
+++ b/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_compiling_the_v1_screen_composition_vectors.cs
@@ -25,7 +25,33 @@ void Because()
.ToArray());
}
- [Fact] void should_compile_the_positive_composition_without_diagnostics() => _positive.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_compile_the_positive_composition_without_diagnostics_beyond_the_action_subject_advisories() => ActionSubjectAdvisories.WithoutAdvisories(_positive.Diagnostics).ShouldBeEmpty();
+
+ [Fact]
+ void should_report_the_action_subject_advisories_for_the_positive_composition()
+ {
+ string[] expected =
+ [
+ "PLAY0818: Action 'CloseWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0818: Action 'CreateWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0818: Action 'CreateWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0818: Action 'RenameWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0818: Action 'RenameWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0819: Action 'CreateWorkItem' now infers 'for screen'; legacy row-name matching would use workItemId from item.workItemId, title from item.title, priority from item.priority"
+ ];
+ ActionSubjectAdvisories.Describe(_positive.Diagnostics).ShouldEqual(expected);
+ }
+
+ [Fact]
+ void should_locate_the_action_subject_advisories_for_the_positive_composition() =>
+ ActionSubjectAdvisories.DescribeLocations(_positive.Diagnostics).ShouldEqual(
+ "PLAY0818@210 CreateWorkItem",
+ "PLAY0818@236 CreateWorkItem",
+ "PLAY0818@259 RenameWorkItem",
+ "PLAY0818@263 CloseWorkItem",
+ "PLAY0818@293 RenameWorkItem",
+ "PLAY0819@210 CreateWorkItem");
+
[Fact] void should_carry_screen_contributions() => Screens(_positive.Value!).SelectMany(screen => screen.Contributions).Count().ShouldEqual(2);
[Fact] void should_carry_document_level_exposures() => _positive.Value!.Exposures.Select(exposure => exposure.Owner).ShouldEqual(["WorkspaceFeatureShell", "EditWorkItemDialog"]);
[Fact] void should_carry_instance_contributions() => _positive.Value!.InstanceContributions.Single().Contributions.Last().Items.Select(item => item.Id).ShouldEqual(["export:csv", "archive"]);
diff --git a/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_loading_the_v1_screen_corpus.cs b/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_loading_the_v1_screen_corpus.cs
index 07a5c552c..4d9894d49 100644
--- a/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_loading_the_v1_screen_corpus.cs
+++ b/Source/DotNET/Screenplay.CanonicalVectors.Specs/for_ScreenCompositionCorpus/when_loading_the_v1_screen_corpus.cs
@@ -46,8 +46,9 @@ void Because()
[Fact]
void should_fail_backend_esm_admission_with_the_expected_diagnostics()
{
- ((string[])[.. _diagnostics.Select(diagnostic => diagnostic.Code)]).ShouldEqual([.. _corpus.EsmDiagnostics.Select(diagnostic => diagnostic.Code)]);
- ((string[])[.. _diagnostics.Select(diagnostic => diagnostic.Message)]).ShouldEqual([.. _corpus.EsmDiagnostics.Select(diagnostic => diagnostic.Message)]);
+ var boundary = ActionSubjectAdvisories.WithoutAdvisories(_diagnostics);
+ ((string[])[.. boundary.Select(diagnostic => diagnostic.Code)]).ShouldEqual([.. _corpus.EsmDiagnostics.Select(diagnostic => diagnostic.Code)]);
+ ((string[])[.. boundary.Select(diagnostic => diagnostic.Message)]).ShouldEqual([.. _corpus.EsmDiagnostics.Select(diagnostic => diagnostic.Message)]);
}
[Fact]
@@ -55,12 +56,58 @@ void should_keep_every_source_form_at_the_same_backend_esm_boundary()
{
foreach (var form in _corpus.SourceForms)
{
- var diagnostics = CompileForExecutableModel(form).ToArray();
+ var diagnostics = ActionSubjectAdvisories.WithoutAdvisories(CompileForExecutableModel(form));
((string[])[.. diagnostics.Select(diagnostic => diagnostic.Code).Order()]).ShouldEqual([.. _corpus.EsmDiagnostics.Select(diagnostic => diagnostic.Code).Order()]);
((string[])[.. diagnostics.Select(diagnostic => diagnostic.Message).Order()]).ShouldEqual([.. _corpus.EsmDiagnostics.Select(diagnostic => diagnostic.Message).Order()]);
}
}
+ [Fact]
+ void should_report_the_action_subject_advisories_for_the_screen_actions()
+ {
+ string[] expected =
+ [
+ "PLAY0818: Action 'AddComment' infers 'for screen' from its declared data and identifier types",
+ "PLAY0818: Action 'CreateWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0818: Action 'RenameWorkItem' infers 'for screen' from its declared data and identifier types",
+ "PLAY0819: Action 'AddComment' now infers 'for screen'; legacy row-name matching would use commentId from item.commentId, workItemId from item.workItemId, text from item.text"
+ ];
+ ActionSubjectAdvisories.Describe(_diagnostics).ShouldEqual(expected);
+ }
+
+ [Fact]
+ void should_report_the_same_action_subject_advisories_for_every_source_form()
+ {
+ var expected = ActionSubjectAdvisories.Describe(_diagnostics);
+ foreach (var form in _corpus.SourceForms)
+ {
+ ActionSubjectAdvisories.Describe(CompileForExecutableModel(form)).ShouldEqual(expected);
+ }
+ }
+
+ [Fact]
+ void should_locate_the_action_subject_advisories_for_the_screen_actions() =>
+ ActionSubjectAdvisories.DescribeLocations(_diagnostics).ShouldEqual(
+ "PLAY0818@229 CreateWorkItem",
+ "PLAY0818@276 RenameWorkItem",
+ "PLAY0818@290 AddComment",
+ "PLAY0819@290 AddComment");
+
+ [Fact]
+ void should_point_every_action_subject_advisory_at_its_action_in_every_source_form()
+ {
+ foreach (var form in _corpus.SourceForms)
+ {
+ var documents = form.Documents.Select(document => (document.DisplayPath, document.StableKey, document.Text)).ToArray();
+ var advisories = CompileForExecutableModel(form).Where(ActionSubjectAdvisories.IsAdvisory).ToArray();
+ advisories.Length.ShouldEqual(4);
+ foreach (var advisory in advisories)
+ {
+ (ActionSubjectAdvisories.SourceLineOf(advisory, documents) ?? string.Empty).ShouldContain($"action {ActionSubjectAdvisories.ActionName(advisory)}");
+ }
+ }
+ }
+
[Fact]
void should_keep_every_probe_backed_by_authored_syntax()
{
diff --git a/Source/DotNET/Screenplay.Contracts/Golden/screenplay-contract.json b/Source/DotNET/Screenplay.Contracts/Golden/screenplay-contract.json
index 1d17f6fba..46f27aa39 100644
--- a/Source/DotNET/Screenplay.Contracts/Golden/screenplay-contract.json
+++ b/Source/DotNET/Screenplay.Contracts/Golden/screenplay-contract.json
@@ -46079,6 +46079,61 @@
"title": "A specification states then invokes a command no reaction of its own slice invokes.",
"reserved": false,
"retired": false
+ },
+ {
+ "code": "PLAY0817",
+ "name": "InvalidActionSubjectClause",
+ "severity": "error",
+ "severities": [
+ "error"
+ ],
+ "title": "Malformed, duplicate or inapplicable action subject clause.",
+ "reserved": false,
+ "retired": false
+ },
+ {
+ "code": "PLAY0818",
+ "name": "InferredActionSubject",
+ "severity": "information",
+ "severities": [
+ "information"
+ ],
+ "title": "The inferred subject of an action without an authored clause.",
+ "reserved": false,
+ "retired": false
+ },
+ {
+ "code": "PLAY0819",
+ "name": "ChangedActionSubject",
+ "severity": "warning",
+ "severities": [
+ "warning"
+ ],
+ "title": "An inferred action subject differs from legacy row-name matching.",
+ "reserved": false,
+ "retired": false
+ },
+ {
+ "code": "PLAY0820",
+ "name": "AmbiguousActionInputBinding",
+ "severity": "warning",
+ "severities": [
+ "warning"
+ ],
+ "title": "Several declared keys are eligible for an action input.",
+ "reserved": false,
+ "retired": false
+ },
+ {
+ "code": "PLAY0821",
+ "name": "UnsupportedActionSubjectContext",
+ "severity": "warning",
+ "severities": [
+ "warning"
+ ],
+ "title": "A guarded screen action cannot obtain an item from a collection.",
+ "reserved": false,
+ "retired": false
}
],
"mcpTools": [
diff --git a/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_discovered_form_submits.cs b/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_discovered_form_submits.cs
index 74df73f0e..018b8eec4 100644
--- a/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_discovered_form_submits.cs
+++ b/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_discovered_form_submits.cs
@@ -5,7 +5,7 @@ namespace Cratis.Screenplay.Completeness.for_NavigationCompleteness;
public class when_a_discovered_form_submits : given.screens
{
- void Establish() => Compile("command Go\nscreen Home\n action Go\nscreen Detail", "on load\n navigate to Home\nform Input for Go\n on submit navigate to Detail");
+ void Establish() => Compile("command Go\nscreen Home\n action Go for screen\nscreen Detail", "on load\n navigate to Home\nform Input for Go\n on submit navigate to Detail");
void Because() => Findings = ModelCompleteness.Check(Compilation, new([CompletenessCheck.Navigation]));
[Fact] void should_follow_the_discovered_forms_submit_edge() => Findings.ShouldBeEmpty();
diff --git a/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_screen_action_navigates.cs b/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_screen_action_navigates.cs
index 5f1108e4f..13a82078a 100644
--- a/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_screen_action_navigates.cs
+++ b/Source/DotNET/Screenplay/Completeness/for_NavigationCompleteness/when_a_screen_action_navigates.cs
@@ -5,7 +5,7 @@ namespace Cratis.Screenplay.Completeness.for_NavigationCompleteness;
public class when_a_screen_action_navigates : given.screens
{
- void Establish() => Compile("command Go\nscreen Home\n action Go\n navigate to Detail\nscreen Detail", "on load\n navigate to Home");
+ void Establish() => Compile("command Go\nscreen Home\n action Go for screen\n navigate to Detail\nscreen Detail", "on load\n navigate to Home");
void Because() => Findings = ModelCompleteness.Check(Compilation, new([CompletenessCheck.Navigation]));
[Fact] void should_follow_the_action_edge() => Findings.ShouldBeEmpty();
diff --git a/Source/DotNET/Screenplay/Diagnostics/DiagnosticCodes.cs b/Source/DotNET/Screenplay/Diagnostics/DiagnosticCodes.cs
index 76dc2880e..b41f8dfdb 100644
--- a/Source/DotNET/Screenplay/Diagnostics/DiagnosticCodes.cs
+++ b/Source/DotNET/Screenplay/Diagnostics/DiagnosticCodes.cs
@@ -26,6 +26,21 @@ namespace Cratis.Screenplay.Diagnostics;
///
public static class DiagnosticCodes
{
+ /// Malformed, duplicate or inapplicable action subject clause.
+ public const string InvalidActionSubjectClause = "PLAY0817";
+
+ /// The inferred subject of an action without an authored clause.
+ public const string InferredActionSubject = "PLAY0818";
+
+ /// An inferred action subject differs from legacy row-name matching.
+ public const string ChangedActionSubject = "PLAY0819";
+
+ /// Several declared keys are eligible for an action input.
+ public const string AmbiguousActionInputBinding = "PLAY0820";
+
+ /// A guarded screen action cannot obtain an item from a collection.
+ public const string UnsupportedActionSubjectContext = "PLAY0821";
+
// The document and its top level.
///
diff --git a/Source/DotNET/Screenplay/Files/for_PlayFileWriter/when_round_tripping_the_invoicing_sample_through_a_folder.cs b/Source/DotNET/Screenplay/Files/for_PlayFileWriter/when_round_tripping_the_invoicing_sample_through_a_folder.cs
index e6eb89b50..1efe67295 100644
--- a/Source/DotNET/Screenplay/Files/for_PlayFileWriter/when_round_tripping_the_invoicing_sample_through_a_folder.cs
+++ b/Source/DotNET/Screenplay/Files/for_PlayFileWriter/when_round_tripping_the_invoicing_sample_through_a_folder.cs
@@ -36,7 +36,7 @@ void Because()
}
[Fact] void should_compile_the_folder_back() => _recompiled.Result.Success.ShouldBeTrue();
- [Fact] void should_resolve_every_reference_across_the_folder() => _recompiled.Result.Diagnostics.Where(diagnostic => diagnostic.Code != DiagnosticCodes.OmittedProductionDestination).Select(_ => _.Code).ShouldContainOnly(DiagnosticCodes.FileConstraintOnlySupportsUniqueness);
+ [Fact] void should_resolve_every_reference_across_the_folder() => _recompiled.Result.Diagnostics.Where(diagnostic => diagnostic.Code is not (DiagnosticCodes.OmittedProductionDestination or DiagnosticCodes.InferredActionSubject or DiagnosticCodes.ChangedActionSubject)).Select(_ => _.Code).ShouldContainOnly(DiagnosticCodes.FileConstraintOnlySupportsUniqueness);
[Fact] void should_discover_every_file_it_wrote() => _recompiled.Sources.Count().ShouldEqual(_written.Count());
[Fact] void should_write_a_file_per_module_feature_slice_and_one_for_the_application() => _written.Count().ShouldEqual(1 + Modules + Features + Slices);
[Fact] void should_give_back_an_equivalent_application() => SyntaxJson.StructurallyEqual(_recompiled.Result.Value!, InPathOrder(_original)).ShouldBeTrue();
diff --git a/Source/DotNET/Screenplay/Parsing/ActionSubjectContext.cs b/Source/DotNET/Screenplay/Parsing/ActionSubjectContext.cs
new file mode 100644
index 000000000..85fc191ea
--- /dev/null
+++ b/Source/DotNET/Screenplay/Parsing/ActionSubjectContext.cs
@@ -0,0 +1,9 @@
+// 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.Parsing;
+
+internal sealed record ActionSubjectContext(IReadOnlyList Data, DeclarationScope Scope, SourceLocation Location);
diff --git a/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.Inputs.cs b/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.Inputs.cs
new file mode 100644
index 000000000..d578e49e6
--- /dev/null
+++ b/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.Inputs.cs
@@ -0,0 +1,81 @@
+// 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.Parsing;
+
+internal sealed partial class ActionSubjectResolver
+{
+ static bool EligibleIdentifier(PropertySyntax input) => input.IsIdentifier && !input.IsGenerated && !input.Type.IsOptional && !input.Type.IsCollection;
+
+ ScreenActionSubjectKind? Subject(ScreenActionSubjectKind? authored, string command, IEnumerable bound, ActionSubjectContext context)
+ {
+ if (authored is not null) return authored;
+ var inputs = CommandInputs(command, context.Scope);
+ var properties = Properties(context.Data, context.Scope);
+ var identifiers = inputs.Where(EligibleIdentifier).ToArray();
+ var item = context.Data is [{ Type.IsCollection: true }] && identifiers is [var identifier] && Keys(identifier, properties).Length == 1;
+ var inferred = item ? ScreenActionSubjectKind.Item : ScreenActionSubjectKind.Screen;
+ _diagnostics.Add(new(DiagnosticSeverity.Information, DiagnosticCodes.InferredActionSubject, $"Action '{command}' infers 'for {inferred.ToString().ToLowerInvariant()}' from its declared data and identifier types", context.Location));
+ ReportMigration(command, bound, context, item);
+
+ // Screen is the legacy omitted default; inference must not manufacture authored metadata.
+ return item ? ScreenActionSubjectKind.Item : null;
+ }
+
+ void ReportMigration(string command, IEnumerable bound, ActionSubjectContext context, bool item)
+ {
+ if (context.Data is not [{ Type.IsCollection: true }]) return;
+ var properties = Properties(context.Data, context.Scope);
+ var inputs = CommandInputs(command, context.Scope).Where(input => !input.IsGenerated && !bound.Contains(input.Name)).ToArray();
+ var legacy = inputs.SelectMany(input => properties.Where(property => string.Equals(input.Name, property.Name, StringComparison.OrdinalIgnoreCase)).Select(property => $"{input.Name} from item.{property.Name}")).ToArray();
+ var current = item ? inputs.Select(input => (Input: input, Property: Binding(input, properties))).Where(pair => pair.Property is not null).Select(pair => $"{pair.Input.Name} from item.{pair.Property!.Name}").ToArray() : [];
+ if (!legacy.SequenceEqual(current))
+ {
+ _diagnostics.Add(new(DiagnosticSeverity.Warning, DiagnosticCodes.ChangedActionSubject, $"Action '{command}' now infers 'for {(item ? "item" : "screen")}'; legacy row-name matching would use {(legacy.Length == 0 ? "no row property" : string.Join(", ", legacy))}", context.Location));
+ }
+ }
+
+ InteractionArgumentSyntax[] Inputs(string command, IEnumerable authored, ActionSubjectContext context)
+ {
+ var result = authored.ToList();
+ if (context.Data.Count != 1) return [.. result];
+ var properties = Properties(context.Data, context.Scope);
+ foreach (var input in CommandInputs(command, context.Scope).Where(input => !input.IsGenerated))
+ {
+ if (result.Exists(argument => argument.Name == input.Name)) continue;
+ if (Binding(input, properties) is { } property)
+ {
+ result.Add(new(input.Name, $"item.{property.Name}", context.Location));
+ }
+ else if (Keys(input, properties).Length > 1)
+ {
+ _diagnostics.Add(new(DiagnosticSeverity.Warning, DiagnosticCodes.AmbiguousActionInputBinding, $"Input '{input.Name}' of action '{command}' matches several declared key fields - write an explicit binding", context.Location));
+ }
+ }
+
+ return [.. result];
+ }
+
+ PropertySyntax? Binding(PropertySyntax input, PropertySyntax[] properties)
+ {
+ var named = properties.Where(property => property.Name == input.Name && _declarations.Compatible(property.Type, input.Type) == true).ToArray();
+ if (named is [var property]) return property;
+ return Keys(input, properties) is [var key] ? key : null;
+ }
+
+ PropertySyntax[] Keys(PropertySyntax input, PropertySyntax[] properties) => EligibleIdentifier(input)
+ ? [.. properties.Where(property => property.IsKey && SameConcept(input, property))]
+ : [];
+
+ PropertySyntax[] Properties(IReadOnlyList subjects, DeclarationScope scope) => subjects.Count == 1
+ ? _declarations.ViewProperties(subjects[0].Type.Name, scope)?.ToArray() ?? []
+ : [];
+
+ PropertySyntax[] CommandInputs(string name, DeclarationScope scope) => _declarations.Resolve(name, scope, slice => slice.Commands, command => command.Name)?.Node.Properties.ToArray() ?? [];
+
+ bool SameConcept(PropertySyntax input, PropertySyntax key) => !key.Type.IsCollection && !key.Type.IsOptional && input.Type.Name == key.Type.Name &&
+ application.Concepts.Count(concept => concept.Name == input.Type.Name) == 1;
+}
diff --git a/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.Repairs.cs b/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.Repairs.cs
new file mode 100644
index 000000000..c7bb01b87
--- /dev/null
+++ b/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.Repairs.cs
@@ -0,0 +1,41 @@
+// 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.Parsing;
+
+internal sealed partial class ActionSubjectResolver
+{
+ internal Dictionary Repairs { get; } = [];
+
+ void Remember(SyntaxNode authored, SyntaxNode effective, IReadOnlyList subjects, DeclarationScope scope)
+ {
+ var command = authored is ScreenActionSyntax action ? action.Command : ((ToolbarItemSyntax)authored).Target;
+ var original = authored is ScreenActionSyntax plain ? plain.Inputs.ToArray() : [.. ((ToolbarItemSyntax)authored).Parameters.Select(parameter => new InteractionArgumentSyntax(parameter.Name, parameter.Binding.Path, parameter.Location))];
+ var properties = Properties(subjects, scope);
+ var legacy = new List(original);
+ var safe = subjects.Count == 1 && subjects[0].Type.IsCollection;
+ foreach (var input in CommandInputs(command, scope).Where(input => !input.IsGenerated && !original.Any(argument => argument.Name == input.Name)))
+ {
+ var matches = properties.Where(property => string.Equals(property.Name, input.Name, StringComparison.OrdinalIgnoreCase)).ToArray();
+ if (matches.Length > 1) safe = false;
+ if (matches.Length == 1) legacy.Add(new(input.Name, $"item.{matches[0].Name}", authored.Location));
+
+ // An implicit key fallback would add a binding the legacy rule never supplied.
+ if (matches.Length == 0 && EligibleIdentifier(input) && properties.Any(property => property.IsKey && SameConcept(input, property))) safe = false;
+ }
+
+ SyntaxNode? retained = safe ? authored switch
+ {
+ ScreenActionSyntax node => node with { Subject = ScreenActionSubjectKind.Item, Inputs = legacy },
+ ToolbarItemSyntax node => node with
+ {
+ Subject = ScreenActionSubjectKind.Item,
+ Parameters = [.. node.Parameters, .. legacy.Skip(original.Length).Select(input => new ScreenNavigationParameterSyntax(input.Name, new(UiBindingKind.DataContext, input.Binding, input.Location), input.Location))]
+ },
+ _ => null
+ } : null;
+ Repairs[authored] = (effective, retained);
+ }
+}
diff --git a/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.cs b/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.cs
new file mode 100644
index 000000000..1c22dd44b
--- /dev/null
+++ b/Source/DotNET/Screenplay/Parsing/ActionSubjectResolver.cs
@@ -0,0 +1,117 @@
+// 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.Parsing;
+
+internal sealed partial class ActionSubjectResolver(ApplicationSyntax application)
+{
+ readonly ConsistencyDeclarations _declarations = new(application, [.. ScreenplayValidator.ScopedSlices(application)]);
+ readonly List _diagnostics = [];
+
+ internal CompilationResult Resolve()
+ {
+ var effective = application with
+ {
+ Modules = [.. application.Modules.Select(module => module with { Features = Features(module.Features) })]
+ };
+ return new(effective, _diagnostics);
+ }
+
+ FeatureSyntax[] Features(IEnumerable features) => [.. features.Select(feature => feature with
+ {
+ Features = Features(feature.Features),
+ Slices = [.. feature.Slices.Select(Slice)]
+ })];
+
+ SliceSyntax Slice(SliceSyntax slice)
+ {
+ var scope = _declarations.Slices.Single(entry => ReferenceEquals(entry.Slice, slice)).Scope;
+ return slice with
+ {
+ Screens = [.. slice.Screens.Select(screen => screen with
+ {
+ Directives = Container(screen.Directives, [], scope),
+ Contributions = [.. screen.Contributions.Select(contribution => contribution with { Directives = Container(contribution.Directives, [], scope) })]
+ })]
+ };
+ }
+
+ ScreenDirectiveSyntax[] Container(IEnumerable directives, IReadOnlyList inherited, DeclarationScope scope)
+ {
+ var nodes = directives.ToArray();
+ var local = nodes.OfType().ToArray();
+ var subjects = local.Length == 0 ? inherited : local;
+ return [.. nodes.Select(node => Directive(node, subjects, scope))];
+ }
+
+ ScreenDirectiveSyntax Directive(ScreenDirectiveSyntax node, IReadOnlyList subjects, DeclarationScope scope) => node switch
+ {
+ ScreenActionSyntax action => PlainAction(action, subjects, scope),
+ ScreenGuardedActionSyntax action => GuardedAction(action, subjects, scope),
+ ScreenToolbarSyntax toolbar => toolbar with { Items = [.. toolbar.Items.Select(item => ToolbarItem(item, subjects, scope))] },
+ ScreenSectionSyntax section => section with { Directives = Container(section.Directives, subjects, scope) },
+ ScreenSlotSyntax slot => slot with { Directives = Container(slot.Directives, subjects, scope) },
+ ScreenTemplateReferenceSyntax template => template with { Slots = [.. template.Slots.Select(slot => slot with { Directives = Container(slot.Directives, subjects, scope) })] },
+ ScreenComponentSyntax component => component with { Outlets = [.. component.Outlets.Select(outlet => outlet with { Directives = Container(outlet.Directives, subjects, scope) })] },
+ _ => node
+ };
+
+ ScreenActionSyntax PlainAction(ScreenActionSyntax action, IReadOnlyList subjects, DeclarationScope scope)
+ {
+ var context = new ActionSubjectContext(subjects, scope, action.Location);
+ var subject = Subject(action.Subject, action.Command, action.Inputs.Select(input => input.Name), context);
+ var inputs = subject == ScreenActionSubjectKind.Item && HasSubject(subjects, action.Location, false) ? Inputs(action.Command, action.Inputs, context) : action.Inputs;
+ var effective = action with { Subject = subject, Inputs = inputs };
+ Remember(action, effective, subjects, scope);
+ return effective;
+ }
+
+ ToolbarItemSyntax ToolbarItem(ToolbarItemSyntax item, IReadOnlyList subjects, DeclarationScope scope)
+ {
+ if (item.Kind != ToolbarItemKind.Action) return item;
+ var context = new ActionSubjectContext(subjects, scope, item.Location);
+ var subject = Subject(item.Subject, item.Target, item.Parameters.Select(parameter => parameter.Name), context);
+ if (subject != ScreenActionSubjectKind.Item || !HasSubject(subjects, item.Location, false))
+ {
+ var effective = item with { Subject = subject };
+ Remember(item, effective, subjects, scope);
+ return effective;
+ }
+ var authored = item.Parameters.ToArray();
+ var inputs = Inputs(item.Target, authored.Select(parameter => new InteractionArgumentSyntax(parameter.Name, string.Empty, parameter.Location)), context);
+ var inferred = inputs.Skip(authored.Length).Select(input => new ScreenNavigationParameterSyntax(input.Name, new(UiBindingKind.DataContext, input.Binding, input.Location), input.Location));
+ var resolved = item with { Subject = subject, Parameters = [.. authored.Concat(inferred)] };
+ Remember(item, resolved, subjects, scope);
+ return resolved;
+ }
+
+ bool HasSubject(IReadOnlyList subjects, Cratis.Screenplay.Diagnostics.SourceLocation location, bool guarded)
+ {
+ if (subjects.Count == 1) return true;
+ var message = guarded ? "A guarded action requires exactly one nearest 'data' subject; no data or equally near data bindings leave 'item' unresolved" : "Action 'for item' requires one resolvable data subject - no item binding is inferred";
+ _diagnostics.Add(new(DiagnosticSeverity.Warning, DiagnosticCodes.UnresolvedActionSubject, message, location));
+ return false;
+ }
+
+ ScreenGuardedActionSyntax GuardedAction(ScreenGuardedActionSyntax action, IReadOnlyList subjects, DeclarationScope scope)
+ {
+ if (!HasSubject(subjects, action.Location, true)) return action;
+ if (action.Subject == ScreenActionSubjectKind.Screen && subjects[0].Type.IsCollection)
+ {
+ _diagnostics.Add(new(DiagnosticSeverity.Warning, DiagnosticCodes.UnsupportedActionSubjectContext, "A guarded action 'for screen' cannot use a collection as 'item' - use a single-record data context or 'for item'", action.Location));
+ return action;
+ }
+
+ return action with
+ {
+ Subject = action.Subject,
+ Alternatives = [.. action.Alternatives.Select(alternative => alternative with { Arguments = Inputs(alternative.Command, alternative.Arguments, new(subjects, scope, alternative.Location)) })],
+ Otherwise = action.Otherwise is { Command: { } command } otherwise
+ ? otherwise with { Arguments = Inputs(command, otherwise.Arguments, new(subjects, scope, otherwise.Location)) }
+ : action.Otherwise
+ };
+ }
+}
diff --git a/Source/DotNET/Screenplay/Parsing/GuardedActionValidator.cs b/Source/DotNET/Screenplay/Parsing/GuardedActionValidator.cs
index 53be8fd23..f82d7ccd4 100644
--- a/Source/DotNET/Screenplay/Parsing/GuardedActionValidator.cs
+++ b/Source/DotNET/Screenplay/Parsing/GuardedActionValidator.cs
@@ -42,6 +42,9 @@ static void ValidateContainer(
case ScreenGuardedActionSyntax action:
ValidateAction(action, subjects, scope, declarations, context);
break;
+ case ScreenActionSyntax action:
+ ValidateArguments(action.Command, action.Inputs, subjects.Count == 1 ? declarations.ViewProperties(subjects[0].Type.Name, scope) : null, scope, declarations, context);
+ break;
case ScreenBehaviorSyntax attached:
ValidateInteraction(attached.Behavior, subjects, scope, declarations, context);
break;
@@ -79,11 +82,6 @@ static void ValidateAction(
ParserContext context)
{
var properties = subjects.Count == 1 ? declarations.ViewProperties(subjects[0].Type.Name, scope) : null;
- if (subjects.Count != 1)
- {
- context.Warning(DiagnosticCodes.UnresolvedActionSubject, "A guarded action requires exactly one nearest 'data' subject; no data or equally near data bindings leave 'item' unresolved", action.Location);
- }
-
foreach (var alternative in action.Alternatives)
{
foreach (var comparison in Comparisons(alternative.Condition))
diff --git a/Source/DotNET/Screenplay/Parsing/ScreenParser.ActionSubjects.cs b/Source/DotNET/Screenplay/Parsing/ScreenParser.ActionSubjects.cs
new file mode 100644
index 000000000..3159aa789
--- /dev/null
+++ b/Source/DotNET/Screenplay/Parsing/ScreenParser.ActionSubjects.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.RegularExpressions;
+using Cratis.Screenplay.Diagnostics;
+using Cratis.Screenplay.Syntax;
+using Cratis.Screenplay.Text;
+
+namespace Cratis.Screenplay.Parsing;
+
+internal static partial class ScreenParser
+{
+ static ScreenActionSubjectKind? ParseSubjectClause(SubjectClauseRequest request, out string header, out string? label)
+ {
+ header = request.Line.Content;
+ var boundary = SubjectHeaderBoundary(header, request.IsHeader);
+ var words = HeaderWordRegex().Matches(header).Cast().Where(word => word.Index >= boundary).ToArray();
+ var clauses = words.Where(word => word.Value == "for").ToArray();
+ var labelWord = words.FirstOrDefault(word => word.Value == "label");
+ label = ParseSubjectLabel(ref header, labelWord);
+
+ if (clauses.Length == 0) return null;
+ var clause = clauses[0];
+ var suffix = SubjectClauseRegex().Match(header[clause.Index..]);
+ if (IsInvalidSubjectClause(request, clauses, suffix, labelWord))
+ {
+ request.Context.Error(DiagnosticCodes.InvalidActionSubjectClause, "Invalid action subject clause - expected one 'for item' or 'for screen' before the label on an action", request.Line.Location);
+ header = header[..boundary].TrimEnd();
+ return null;
+ }
+
+ header = header[..clause.Index].TrimEnd();
+ return suffix.Groups[1].Value == "item" ? ScreenActionSubjectKind.Item : ScreenActionSubjectKind.Screen;
+ }
+
+ static int SubjectHeaderBoundary(string content, Func isHeader)
+ {
+ // A complete existing header wins, including keyword-looking required operands.
+ if (isHeader(content)) return content.Length;
+ return HeaderWordRegex().Matches(content).Cast().Reverse()
+ .FirstOrDefault(word => isHeader(content[..word.Index].TrimEnd()))?.Index ?? content.Length;
+ }
+
+ static bool IsActionHeader(string content) => ActionRegex().IsMatch(content) || GuardedActionRegex().IsMatch(content);
+
+ static void ValidateNonActionSubjectClause(ParserContext context, SourceLine line)
+ {
+ var grammar = LineText.FirstWord(line.Content) switch
+ {
+ "data" => DataRegex(),
+ "navigate" => NavigateRegex(),
+ "component" => ComponentRegex(),
+ "title" => TitleRegex(),
+ _ => null
+ };
+
+ // Expressions, code and open-ended template references belong to their own parsers.
+ if (grammar is not null) ParseSubjectClause(new(context, line, false, grammar.IsMatch), out _, out _);
+ }
+
+ static string? ParseSubjectLabel(ref string header, Match? labelWord)
+ {
+ if (labelWord is null) return null;
+ var match = LabelRegex().Match(header[labelWord.Index..]);
+ if (!match.Success) return null;
+ var label = OperandText(match, 1);
+ header = header[..labelWord.Index].TrimEnd();
+ return label;
+ }
+
+ static bool IsInvalidSubjectClause(SubjectClauseRequest request, Match[] clauses, Match suffix, Match? labelWord)
+ {
+ if (!request.Action) return true;
+ if (clauses.Length != 1) return true;
+ return !suffix.Success || (labelWord is not null && labelWord.Index < clauses[0].Index);
+ }
+
+ static InteractionArgumentSyntax? ParsePlainActionInput(ParserContext context, SourceLine line, IReadOnlyList inputs)
+ {
+ var match = InteractionParser.WithRegex().Match(line.Content);
+ if (match.Success)
+ {
+ var name = match.Groups[1].Value;
+ if (inputs.Any(input => input.Name == name)) context.Error(DiagnosticCodes.InvalidInteractionArgument, $"Duplicate argument '{name}' - supply each input only once", line.Location);
+ return new(name, match.Groups[2].Value.Trim(), line.Location);
+ }
+ context.Error(DiagnosticCodes.InvalidInteractionArgument, $"Invalid argument '{line.Content}' - expected 'with from '", line.Location);
+ context.SkipBlock(line.Indent);
+ return null;
+ }
+
+ [GeneratedRegex("\"" + StringLiteral.BodyPattern + "\"|\\S+", RegexOptions.NonBacktracking)]
+ private static partial Regex HeaderWordRegex();
+
+ [GeneratedRegex(@"^item\s+\S+\s+action\s", RegexOptions.NonBacktracking)]
+ private static partial Regex ToolbarActionHeaderRegex();
+
+ [GeneratedRegex(@"^for\s+(item|screen)$", RegexOptions.NonBacktracking)]
+ private static partial Regex SubjectClauseRegex();
+
+ sealed record SubjectClauseRequest(ParserContext Context, SourceLine Line, bool Action, Func IsHeader);
+}
diff --git a/Source/DotNET/Screenplay/Parsing/ScreenParser.GuardedActions.cs b/Source/DotNET/Screenplay/Parsing/ScreenParser.GuardedActions.cs
index a057531f0..408b1fe2b 100644
--- a/Source/DotNET/Screenplay/Parsing/ScreenParser.GuardedActions.cs
+++ b/Source/DotNET/Screenplay/Parsing/ScreenParser.GuardedActions.cs
@@ -12,7 +12,7 @@ internal static partial class ScreenParser
{
internal static void ValidateGuardedCondition(ParserContext context, ConditionSyntax condition) => ValidateActionCondition(context, condition);
- static ScreenGuardedActionSyntax ParseGuardedAction(ParserContext context, SourceLine line, string label)
+ static ScreenGuardedActionSyntax ParseGuardedAction(ParserContext context, SourceLine line, string label, ScreenActionSubjectKind? subject)
{
var alternatives = new List();
ScreenActionOtherwiseSyntax? otherwise = null;
@@ -82,7 +82,7 @@ static ScreenGuardedActionSyntax ParseGuardedAction(ParserContext context, Sourc
context.Error(DiagnosticCodes.GuardedActionWithoutAlternatives, "A guarded action must declare at least one 'when' alternative", line.Location);
}
- return new(label, alternatives, line.Location) { Otherwise = otherwise, Navigate = navigate };
+ return new(label, alternatives, line.Location) { Otherwise = otherwise, Navigate = navigate, Subject = subject };
}
static List ParseActionArguments(ParserContext context, SourceLine line)
diff --git a/Source/DotNET/Screenplay/Parsing/ScreenParser.cs b/Source/DotNET/Screenplay/Parsing/ScreenParser.cs
index 476ad05d4..5eeeca902 100644
--- a/Source/DotNET/Screenplay/Parsing/ScreenParser.cs
+++ b/Source/DotNET/Screenplay/Parsing/ScreenParser.cs
@@ -158,6 +158,8 @@ internal static ScreenToolbarSyntax ParseToolbar(ParserContext context, SourceLi
static ScreenDirectiveSyntax? ParseDirective(ParserContext context, SourceLine line)
{
+ ValidateNonActionSubjectClause(context, line);
+
switch (LineText.FirstWord(line.Content))
{
case "data":
@@ -216,13 +218,19 @@ internal static ScreenToolbarSyntax ParseToolbar(ParserContext context, SourceLi
static ScreenDirectiveSyntax? ParseAction(ParserContext context, SourceLine line)
{
- var guarded = GuardedActionRegex().Match(line.Content);
+ var subject = ParseSubjectClause(new(context, line, true, IsActionHeader), out var header, out var inlineLabel);
+ var guarded = GuardedActionRegex().Match(header);
if (guarded.Success)
{
- return ParseGuardedAction(context, line, OperandText(guarded, 1));
+ return ParseGuardedAction(context, line, OperandText(guarded, 1), subject);
}
- var match = ActionRegex().Match(line.Content);
+ return ParsePlainAction(context, line, (header, inlineLabel, subject));
+ }
+
+ static ScreenActionSyntax? ParsePlainAction(ParserContext context, SourceLine line, (string Header, string? Label, ScreenActionSubjectKind? Subject) header)
+ {
+ var match = ActionRegex().Match(header.Header);
if (!match.Success)
{
context.Error(DiagnosticCodes.InvalidActionDirective, $"Invalid action directive '{line.Content}' - expected 'action '", line.Location);
@@ -230,9 +238,10 @@ internal static ScreenToolbarSyntax ParseToolbar(ParserContext context, SourceLi
return null;
}
- string? label = null;
- SourceLocation? labelLocation = null;
+ var label = header.Label;
+ var labelLocation = label is null ? null : line.Location;
ScreenNavigateSyntax? navigate = null;
+ var inputs = new List();
while (context.TryPeekChild(line.Indent, out var child))
{
@@ -247,6 +256,10 @@ internal static ScreenToolbarSyntax ParseToolbar(ParserContext context, SourceLi
{
navigate = ParseNavigate(context, child.Content, child);
}
+ else if (LineText.FirstWord(child.Content) == "with")
+ {
+ AddPlainActionInput(context, child, inputs);
+ }
else
{
context.Error(DiagnosticCodes.UnknownActionDirective, $"Unexpected '{child.Content}' in action - expected 'label \"...\"' or 'navigate to ...'", child.Location);
@@ -255,10 +268,17 @@ internal static ScreenToolbarSyntax ParseToolbar(ParserContext context, SourceLi
return new ScreenActionSyntax(match.Groups[1].Value, label, navigate, line.Location)
{
+ Subject = header.Subject,
+ Inputs = inputs,
DirectiveLocations = labelLocation is null ? [] : new Dictionary { ["label"] = labelLocation }
};
}
+ static void AddPlainActionInput(ParserContext context, SourceLine child, List inputs)
+ {
+ if (ParsePlainActionInput(context, child, inputs) is { } input) inputs.Add(input);
+ }
+
static ScreenTemplateReferenceSyntax ParseTemplateReference(ParserContext context, SourceLine line)
{
var name = line.Content["template".Length..].Trim();
@@ -530,7 +550,9 @@ static ComponentOutletSyntax ParseComponentOutlet(ParserContext context, SourceL
static ToolbarItemSyntax? ParseToolbarItem(ParserContext context, SourceLine line)
{
- var match = ToolbarItemRegex().Match(line.Content);
+ var isAction = ToolbarActionHeaderRegex().IsMatch(line.Content);
+ var subject = ParseSubjectClause(new(context, line, isAction, ToolbarItemRegex().IsMatch), out var header, out var inlineLabel);
+ var match = ToolbarItemRegex().Match(header);
if (!match.Success)
{
context.Error(DiagnosticCodes.UnknownScreenDirective, $"Invalid toolbar item '{line.Content}' - expected 'item action ', 'item navigate to ', 'item dialog ' or 'button title \"\" slice '", line.Location);
@@ -544,8 +566,8 @@ static ComponentOutletSyntax ParseComponentOutlet(ParserContext context, SourceL
"navigate" => ToolbarItemKind.Navigate,
_ => ToolbarItemKind.Dialog
};
- var item = new ToolbarItemSyntax(match.Groups[1].Value, kind, match.Groups[3].Value, line.Location);
- string? label = null;
+ var item = new ToolbarItemSyntax(match.Groups[1].Value, kind, match.Groups[3].Value, line.Location) { Subject = subject };
+ var label = inlineLabel;
string? icon = null;
string? confirm = null;
var parameters = new List();
diff --git a/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs b/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs
index 6e1addf80..ab79904c0 100644
--- a/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs
+++ b/Source/DotNET/Screenplay/Parsing/ScreenplayValidator.cs
@@ -46,6 +46,7 @@ public static ApplicationSyntax Validate(ApplicationSyntax application, ParserCo
ValidateSlices(application, slices, names, context);
ValidateScopedReferences(application, expansion, names, context);
ValidatePresentation(application, context);
+ foreach (var diagnostic in ScreenActionSubjects.Resolve(application).Diagnostics) context.Add(diagnostic);
SampleDataValidator.Validate(application, slices, context);
foreach (var diagnostic in expansion.Diagnostics.Where(diagnostic => diagnostic.Code != DiagnosticCodes.UnsynthesizablePersonaCaller && !context.Diagnostics.Contains(diagnostic))) context.Add(diagnostic);
diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.GuardedActions.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.GuardedActions.cs
index f64a0053c..4b3d61049 100644
--- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.GuardedActions.cs
+++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.GuardedActions.cs
@@ -10,9 +10,11 @@ namespace Cratis.Screenplay.Printing;
///
public partial class ScreenplayPrinter
{
+ static string WriteActionSubject(ScreenActionSubjectKind? subject) => subject is null ? string.Empty : $" for {subject.Value.ToString().ToLowerInvariant()}";
+
void WriteScreenGuardedAction(ScreenplayWriter writer, ScreenGuardedActionSyntax action)
{
- writer.Line($"action {ScreenplaySyntaxText.LocalizableString(action.Label)}");
+ writer.Line($"action {ScreenplaySyntaxText.LocalizableString(action.Label)}{WriteActionSubject(action.Subject)}");
using (writer.Indent())
{
foreach (var alternative in action.Alternatives)
diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Screens.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Screens.cs
index 6deea22d6..18ec38c30 100644
--- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Screens.cs
+++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Screens.cs
@@ -10,6 +10,8 @@ namespace Cratis.Screenplay.Printing;
///
public partial class ScreenplayPrinter
{
+ static bool HasScreenActionBody(ScreenActionSyntax action) => action.Label is not null || action.Navigate is not null || action.Inputs.Any();
+
void WriteScreen(ScreenplayWriter writer, ScreenSyntax screen)
{
using var anchor = writer.Anchor(screen);
@@ -156,8 +158,8 @@ string WriteUiBinding(ScreenplayWriter writer, UiBindingSyntax binding)
void WriteScreenAction(ScreenplayWriter writer, ScreenActionSyntax action)
{
using var anchor = writer.Anchor(action);
- writer.Line($"action {action.Command}");
- if (action.Label is null && action.Navigate is null)
+ writer.Line($"action {action.Command}{WriteActionSubject(action.Subject)}");
+ if (!HasScreenActionBody(action))
{
return;
}
@@ -169,6 +171,8 @@ void WriteScreenAction(ScreenplayWriter writer, ScreenActionSyntax action)
writer.DirectiveLine($"label {ScreenplaySyntaxText.LocalizableString(action.Label)}", action, "label");
}
+ foreach (var input in action.Inputs) writer.Line($"with {input.Name} from {input.Binding}", input);
+
if (action.Navigate is not null)
{
writer.Line(WriteScreenNavigate(action.Navigate), action.Navigate);
@@ -371,7 +375,7 @@ void WriteToolbarItem(ScreenplayWriter writer, ToolbarItemSyntax item)
ToolbarItemKind.Dialog => $"item {item.Name} dialog {item.Target}",
_ => $"item {item.Name} action {item.Target}"
};
- writer.Line(head);
+ writer.Line(head + WriteActionSubject(item.Subject));
using (writer.Indent())
{
if (item.Label is not null)
diff --git a/Source/DotNET/Screenplay/ScreenActionSubjects.cs b/Source/DotNET/Screenplay/ScreenActionSubjects.cs
new file mode 100644
index 000000000..347077d09
--- /dev/null
+++ b/Source/DotNET/Screenplay/ScreenActionSubjects.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay.Parsing;
+using Cratis.Screenplay.Syntax;
+
+namespace Cratis.Screenplay;
+
+///
+/// Resolves action subjects and inputs against the complete application's declarations.
+///
+public static class ScreenActionSubjects
+{
+ ///
+ /// Creates an effective application without modifying the authored syntax tree.
+ ///
+ /// The assembled application, including imported declarations.
+ /// The effective syntax and subject-resolution diagnostics.
+ public static CompilationResult Resolve(ApplicationSyntax application) => new ActionSubjectResolver(application).Resolve();
+}
diff --git a/Source/DotNET/Screenplay/Syntax/ScreenActionSubjectKind.cs b/Source/DotNET/Screenplay/Syntax/ScreenActionSubjectKind.cs
new file mode 100644
index 000000000..14dafe85e
--- /dev/null
+++ b/Source/DotNET/Screenplay/Syntax/ScreenActionSubjectKind.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Syntax;
+
+///
+/// Identifies the data subject used by a screen action.
+///
+public enum ScreenActionSubjectKind
+{
+ /// The item supplied by a table or record context.
+ Item,
+
+ /// The nearest single-record screen data context.
+ Screen
+}
diff --git a/Source/DotNET/Screenplay/Syntax/ScreenGuardedActionSyntax.cs b/Source/DotNET/Screenplay/Syntax/ScreenGuardedActionSyntax.cs
index 610f2ed5f..a6c73ca79 100644
--- a/Source/DotNET/Screenplay/Syntax/ScreenGuardedActionSyntax.cs
+++ b/Source/DotNET/Screenplay/Syntax/ScreenGuardedActionSyntax.cs
@@ -18,6 +18,11 @@ public record ScreenGuardedActionSyntax(string Label, IEnumerable
public ScreenActionOtherwiseSyntax? Otherwise { get; init; }
+ ///
+ /// Gets the explicitly authored subject, or null for the contextual default.
+ ///
+ public ScreenActionSubjectKind? Subject { get; init; }
+
///
/// Gets the navigation performed after successful execution of the selected command.
///
diff --git a/Source/DotNET/Screenplay/Syntax/ScreenSyntax.cs b/Source/DotNET/Screenplay/Syntax/ScreenSyntax.cs
index f7da953c9..c36e24e47 100644
--- a/Source/DotNET/Screenplay/Syntax/ScreenSyntax.cs
+++ b/Source/DotNET/Screenplay/Syntax/ScreenSyntax.cs
@@ -77,7 +77,18 @@ public record ScreenDataSyntax(TypeRefSyntax Type, string Query, string? By, Sou
/// The optional display label.
/// The optional performed after the action.
/// The where the node starts in the source text.
-public record ScreenActionSyntax(string Command, string? Label, ScreenNavigateSyntax? Navigate, SourceLocation Location) : ScreenDirectiveSyntax(Location);
+public record ScreenActionSyntax(string Command, string? Label, ScreenNavigateSyntax? Navigate, SourceLocation Location) : ScreenDirectiveSyntax(Location)
+{
+ ///
+ /// Gets the explicitly authored subject, or null for the contextual default.
+ ///
+ public ScreenActionSubjectKind? Subject { get; init; }
+
+ ///
+ /// Gets the explicitly authored command input bindings.
+ ///
+ public IEnumerable Inputs { get; init; } = [];
+}
///
/// Represents a navigate to directive.
@@ -370,6 +381,11 @@ public record ToolbarButtonSyntax(string Id, string Title, SourceLocation Locati
/// The where the node starts in the source text.
public record ToolbarItemSyntax(string Name, ToolbarItemKind Kind, string Target, SourceLocation Location) : SyntaxNode(Location)
{
+ ///
+ /// Gets the explicitly authored command subject.
+ ///
+ public ScreenActionSubjectKind? Subject { get; init; }
+
///
/// Gets the item label.
///
diff --git a/Source/DotNET/Screenplay/Syntax/ScreenplaySyntaxWalker.Screens.cs b/Source/DotNET/Screenplay/Syntax/ScreenplaySyntaxWalker.Screens.cs
index fa5cb0213..2ec9581c3 100644
--- a/Source/DotNET/Screenplay/Syntax/ScreenplaySyntaxWalker.Screens.cs
+++ b/Source/DotNET/Screenplay/Syntax/ScreenplaySyntaxWalker.Screens.cs
@@ -116,6 +116,7 @@ public virtual void VisitScreenData(ScreenDataSyntax syntax)
public virtual void VisitScreenAction(ScreenActionSyntax syntax)
{
VisitNode(syntax);
+ foreach (var input in syntax.Inputs) VisitInteractionArgument(input);
if (syntax.Navigate is not null)
{
diff --git a/Source/DotNET/Screenplay/Syntax/Serialization/ActionSubjectInvariants.cs b/Source/DotNET/Screenplay/Syntax/Serialization/ActionSubjectInvariants.cs
new file mode 100644
index 000000000..3e7b75ab6
--- /dev/null
+++ b/Source/DotNET/Screenplay/Syntax/Serialization/ActionSubjectInvariants.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Syntax.Serialization;
+
+internal static class ActionSubjectInvariants
+{
+ internal static void Validate(SyntaxNode node)
+ {
+ if (node is ToolbarItemSyntax { Subject: not null, Kind: not ToolbarItemKind.Action })
+ {
+ throw new InvalidSyntaxJson("Only an action toolbar item can have a subject.");
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonReader.cs b/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonReader.cs
index f86b6cd02..d67bb505f 100644
--- a/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonReader.cs
+++ b/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonReader.cs
@@ -40,6 +40,7 @@ internal static SyntaxNode Read(JsonElement value, Type expected, string path, i
var node = descriptor.Create(values);
ImplementationInvariants.Validate(node);
+ ActionSubjectInvariants.Validate(node);
return node;
}
diff --git a/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonWriter.cs b/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonWriter.cs
index 21e7ca034..6cfa530f1 100644
--- a/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonWriter.cs
+++ b/Source/DotNET/Screenplay/Syntax/Serialization/SyntaxJsonWriter.cs
@@ -11,6 +11,7 @@ internal static class SyntaxJsonWriter
{
SyntaxJson.CheckDepth(depth, path);
ImplementationInvariants.Validate(node);
+ ActionSubjectInvariants.Validate(node);
var descriptor = SyntaxKinds.For(node.GetType());
var result = new Dictionary(StringComparer.Ordinal) { ["kind"] = descriptor.Type.Name };
foreach (var member in descriptor.Members)
@@ -53,7 +54,9 @@ internal static class SyntaxJsonWriter
}
static bool OmitDefaults(SyntaxNode node, string member, object? value) =>
- ClockSyntaxDefaults.Omit(node, member, value) || CompositionDefaults.Omit(node, member, value);
+ ClockSyntaxDefaults.Omit(node, member, value) || CompositionDefaults.Omit(node, member, value) ||
+ (node is ScreenActionSyntax or ScreenGuardedActionSyntax or ToolbarItemSyntax && member == "subject" && value is null) ||
+ (node is ScreenActionSyntax && member == "inputs" && value is IEnumerable inputs && !inputs.Any());
static object? WriteMember(SyntaxMember member, object? value, string path, int depth)
{
diff --git a/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/legacy_library_action_subjects.cs b/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/legacy_library_action_subjects.cs
new file mode 100644
index 000000000..2ac0ff858
--- /dev/null
+++ b/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/legacy_library_action_subjects.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Syntax.Serialization.for_SyntaxJson.given;
+
+// Only the living Library sample uses this projection. The new action-subject vectors protect its
+// current metadata; its pre-0110 frozen bytes still protect every preexisting member and value.
+internal static class legacy_library_action_subjects
+{
+ internal static ApplicationSyntax Without(ApplicationSyntax application) => application with
+ {
+ Modules = application.Modules.Select(Module)
+ };
+
+ static ModuleSyntax Module(ModuleSyntax module) => module with { Features = module.Features.Select(Feature) };
+
+ static FeatureSyntax Feature(FeatureSyntax feature) => feature with { Features = feature.Features.Select(Feature), Slices = feature.Slices.Select(Slice) };
+
+ static SliceSyntax Slice(SliceSyntax slice) => slice with { Screens = slice.Screens.Select(Screen) };
+
+ static ScreenSyntax Screen(ScreenSyntax screen) => screen with { Directives = screen.Directives.Select(Directive) };
+
+ static ScreenDirectiveSyntax Directive(ScreenDirectiveSyntax directive) => directive is ScreenActionSyntax action
+ ? action with { Subject = null, Inputs = [] }
+ : directive;
+}
diff --git a/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/syntax_examples.cs b/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/syntax_examples.cs
index 850d70143..177aaf0bc 100644
--- a/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/syntax_examples.cs
+++ b/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/given/syntax_examples.cs
@@ -32,6 +32,7 @@ internal static SyntaxNode Create(Type type, int depth = 0)
// Implementation intent adds a single authoritative payload invariant; keep this example valid
// while still independently populating every other structural member.
+ node = WithActionSubjectContract(node);
return node switch
{
ConceptSyntax concept => concept with { Type = "Enum" },
@@ -63,6 +64,8 @@ internal static SyntaxNode Create(Type type, int depth = 0)
};
}
+ static SyntaxNode WithActionSubjectContract(SyntaxNode node) => node is ToolbarItemSyntax item ? item with { Kind = ToolbarItemKind.Action } : node;
+
internal static bool SameValues(object? left, object? right)
{
if (left is SyntaxNode node && right is SyntaxNode other)
diff --git a/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/when_exporting_action_subject_fixture_packs.cs b/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/when_exporting_action_subject_fixture_packs.cs
new file mode 100644
index 000000000..9f03c7414
--- /dev/null
+++ b/Source/DotNET/Screenplay/Syntax/Serialization/for_SyntaxJson/when_exporting_action_subject_fixture_packs.cs
@@ -0,0 +1,214 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Cratis.Screenplay.Diagnostics;
+
+namespace Cratis.Screenplay.Syntax.Serialization.for_SyntaxJson;
+
+public class when_exporting_action_subject_fixture_packs : Specification
+{
+ readonly Dictionary _exports = [];
+ ApplicationSyntax _raw;
+ ApplicationSyntax _effective;
+ JsonElement _before;
+ string _directory;
+
+ void Establish()
+ {
+ _directory = Fixtures();
+ var compiled = new ScreenplayCompiler().Compile(File.ReadAllText(Path.Combine(_directory, "source.play")));
+ _raw = compiled.Value!;
+ _before = SyntaxJson.Serialize(_raw);
+ _exports["diagnostics"] = JsonSerializer.SerializeToElement(compiled.Diagnostics.Where(Relevant).Select(diagnostic => new
+ {
+ code = diagnostic.Code,
+ severity = diagnostic.Severity.ToString().ToLowerInvariant(),
+ message = diagnostic.Message,
+ location = new { line = diagnostic.Location.Line, column = diagnostic.Location.Column }
+ }));
+ }
+
+ void Because()
+ {
+ _effective = ScreenActionSubjects.Resolve(_raw).Value!;
+ _exports["raw.syntax"] = SyntaxJson.Serialize(_raw);
+ _exports["effective.syntax"] = SyntaxJson.Serialize(_effective);
+ _exports["manifest"] = JsonSerializer.SerializeToElement(new
+ {
+ protocol = "Cratis.ActionSubjects/1",
+ authored = "source.play",
+ rawSyntaxJson = "native.raw.syntax.json",
+ effectiveSyntaxJson = "native.effective.syntax.json",
+ diagnostics = "native.diagnostics.json",
+ context = "native.context.json",
+ cases = _raw.Modules.First().Features.First().Slices.First().Screens.Select((screen, index) => new { name = screen.Name, focusPath = $"$.modules[0].features[0].slices[0].screens[{index}]" })
+ });
+ _exports["context"] = JsonSerializer.SerializeToElement(new
+ {
+ source = "source.play",
+ application = _before,
+ subjects = Subjects(_before, "$", new(null, null, [])).ToArray(),
+ locations = Locations(_raw, "$").OrderBy(entry => entry.Path, StringComparer.Ordinal).Select(entry => new
+ {
+ path = entry.Path,
+ kind = entry.Node.GetType().Name,
+ location = new { line = entry.Node.Location.Line, column = entry.Node.Location.Column }
+ })
+ });
+ }
+
+ [Theory]
+ [InlineData("InferredItem", "item.rowId")]
+ [InlineData("SameName", "item.itemId")]
+ [InlineData("CaseMismatch", "item.rowId")]
+ [InlineData("QualifiedDeclarations", "item.rowId")]
+ [InlineData("SlotScope", "item.rowId")]
+ [InlineData("OutletScope", "item.itemId")]
+ void should_resolve_declared_identity_in_each_scope(string screen, string binding)
+ {
+ var action = Actions(screen).Single();
+ action.Subject.ShouldEqual(ScreenActionSubjectKind.Item);
+ action.Inputs.Single(input => input.Name == "itemId").Binding.ShouldEqual(binding);
+ }
+
+ [Theory]
+ [InlineData("ForeignKey")]
+ [InlineData("OptionalIdentifier")]
+ [InlineData("GeneratedIdentifier")]
+ [InlineData("PrimitiveIdentifier")]
+ [InlineData("AmbiguousInferred")]
+ [InlineData("MissingKey")]
+ [InlineData("UnresolvedDeclaration")]
+ void should_not_invent_identity(string screen)
+ {
+ var action = Actions(screen).Single();
+ action.Subject.ShouldBeNull();
+ action.Inputs.ShouldBeEmpty();
+ }
+
+ IEnumerable Actions(string name)
+ {
+ var walker = new Cratis.Screenplay.Syntax.for_ScreenplaySyntaxWalker.given.a_counting_walker();
+ walker.VisitScreen(_effective.Modules.First().Features.First().Slices.First().Screens.Single(screen => screen.Name == name));
+ return walker.Nodes.OfType();
+ }
+
+ [Fact] void should_leave_authored_syntax_unchanged() => SyntaxJson.StructurallyEqual(_raw, SyntaxJson.Deserialize(_before)).ShouldBeTrue();
+ [Fact] void should_be_idempotent() => SyntaxJson.StructurallyEqual(_effective, ScreenActionSubjects.Resolve(_effective).Value!).ShouldBeTrue();
+
+ [Fact]
+ void should_retain_locations_of_every_authored_node()
+ {
+ var effective = Locations(_effective, "$").ToDictionary(entry => entry.Path, entry => entry.Node.Location);
+ foreach (var (path, node) in Locations(_raw, "$")) effective[path].ShouldEqual(node.Location);
+ }
+
+ [Fact]
+ void should_match_the_native_fixture_pack()
+ {
+ var regenerate = Environment.GetEnvironmentVariable("SCREENPLAY_REGENERATE_ACTION_SUBJECT_FIXTURES") == "1";
+ foreach (var (name, value) in _exports)
+ {
+ var path = Path.Combine(_directory, $"native.{name}.json");
+ if (regenerate) File.WriteAllText(path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }) + "\n");
+ else Assert.True(JsonElement.DeepEquals(value, JsonDocument.Parse(File.ReadAllText(path)).RootElement), path);
+ }
+ Assert.False(regenerate, "Fixture pack regenerated; review and rerun without SCREENPLAY_REGENERATE_ACTION_SUBJECT_FIXTURES");
+ }
+
+ [Fact]
+ void should_agree_with_typescript_on_authored_and_effective_members()
+ {
+ var context = JsonDocument.Parse(File.ReadAllText(Path.Combine(_directory, "typescript.context.json"))).RootElement;
+ Assert.True(JsonElement.DeepEquals(context.GetProperty("subjects"), _exports["context"].GetProperty("subjects")));
+ foreach (var name in new[] { "raw.syntax", "effective.syntax" })
+ {
+ var expected = JsonDocument.Parse(File.ReadAllText(Path.Combine(_directory, $"typescript.{name}.json"))).RootElement;
+ Compare(expected, _exports[name], "$");
+ }
+ }
+
+ sealed record SubjectScope(string? Screen, string? Slice, string[] Data);
+
+ static IEnumerable