From 8987ebbba8a824ae04b35f03b583e326b893d162 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 18:18:21 +0100 Subject: [PATCH 1/8] Add guarded action and interaction branch models --- ...st_an_earlier_scene_constructs_a_record.cs | 3 ++ .../when_round_tripping_a_guarded_action.cs | 40 ++++++++++++++ .../Model/Guards/ComparisonGuardCondition.cs | 13 +++++ Source/DotNET/Model/Guards/GuardCondition.cs | 14 +++++ Source/DotNET/Model/Guards/GuardOperator.cs | 37 +++++++++++++ .../Model/Guards/GuardedActionAlternative.cs | 12 +++++ .../Model/Guards/GuardedActionFallback.cs | 12 +++++ .../Guards/GuardedActionFallbackOutcome.cs | 23 ++++++++ .../Model/Guards/GuardedActionProperties.cs | 53 +++++++++++++++++++ .../Model/Guards/LogicalGuardCondition.cs | 12 +++++ .../Model/Guards/LogicalGuardOperator.cs | 23 ++++++++ .../DotNET/Model/Guards/SubjectReference.cs | 10 ++++ .../Interactions/InteractionAlternative.cs | 13 +++++ .../Model/Interactions/InteractionBinding.cs | 20 ++++++- .../model/guards/ComparisonGuardCondition.ts | 15 ++++++ .../JavaScript/model/guards/GuardCondition.ts | 7 +++ .../JavaScript/model/guards/GuardOperator.ts | 13 +++++ .../model/guards/GuardedActionAlternative.ts | 13 +++++ .../model/guards/GuardedActionFallback.ts | 13 +++++ .../guards/GuardedActionFallbackOutcome.ts | 7 +++ .../model/guards/GuardedActionProperties.ts | 21 ++++++++ .../model/guards/LogicalGuardCondition.ts | 14 +++++ .../model/guards/LogicalGuardOperator.ts | 7 +++ .../model/guards/SubjectReference.ts | 8 +++ Source/JavaScript/model/guards/index.ts | 13 +++++ Source/JavaScript/model/index.ts | 1 + .../interactions/InteractionAlternative.ts | 12 +++++ .../model/interactions/InteractionBinding.ts | 5 +- Source/JavaScript/model/interactions/index.ts | 1 + scene-model-shape.json | 15 +++++- 30 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs create mode 100644 Source/DotNET/Model/Guards/ComparisonGuardCondition.cs create mode 100644 Source/DotNET/Model/Guards/GuardCondition.cs create mode 100644 Source/DotNET/Model/Guards/GuardOperator.cs create mode 100644 Source/DotNET/Model/Guards/GuardedActionAlternative.cs create mode 100644 Source/DotNET/Model/Guards/GuardedActionFallback.cs create mode 100644 Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs create mode 100644 Source/DotNET/Model/Guards/GuardedActionProperties.cs create mode 100644 Source/DotNET/Model/Guards/LogicalGuardCondition.cs create mode 100644 Source/DotNET/Model/Guards/LogicalGuardOperator.cs create mode 100644 Source/DotNET/Model/Guards/SubjectReference.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionAlternative.cs create mode 100644 Source/JavaScript/model/guards/ComparisonGuardCondition.ts create mode 100644 Source/JavaScript/model/guards/GuardCondition.ts create mode 100644 Source/JavaScript/model/guards/GuardOperator.ts create mode 100644 Source/JavaScript/model/guards/GuardedActionAlternative.ts create mode 100644 Source/JavaScript/model/guards/GuardedActionFallback.ts create mode 100644 Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts create mode 100644 Source/JavaScript/model/guards/GuardedActionProperties.ts create mode 100644 Source/JavaScript/model/guards/LogicalGuardCondition.ts create mode 100644 Source/JavaScript/model/guards/LogicalGuardOperator.ts create mode 100644 Source/JavaScript/model/guards/SubjectReference.ts create mode 100644 Source/JavaScript/model/guards/index.ts create mode 100644 Source/JavaScript/model/interactions/InteractionAlternative.ts diff --git a/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs b/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs index af248d8c..db494ad4 100644 --- a/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs +++ b/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Scene.Model.Common; +using Cratis.Scene.Model.Interactions; using Cratis.Scene.Model.Profiles; using Cratis.Scene.Model.SampleData; using Cratis.Scene.Model.Screens; @@ -20,6 +22,7 @@ static readonly (Type Type, Type[] Parameters)[] _constructors = [ (typeof(UiProfile), [typeof(string), typeof(string), typeof(IReadOnlyList), typeof(TargetSizeClass?), typeof(string), typeof(string), typeof(SampleDataSelection), typeof(RootTemplate), typeof(ProfileAppearance), typeof(ProfileDefaults)]), (typeof(ProfileAppearance), [typeof(ColorScheme?), typeof(string)]), + (typeof(InteractionBinding), [typeof(InteractionTrigger), typeof(IReadOnlyList), typeof(BindingExpression)]), (typeof(SampleDataPackReference), [typeof(string), typeof(string), typeof(string), typeof(string)]), (typeof(TemplateMetadata), [typeof(string), typeof(string), typeof(IReadOnlyList), typeof(TemplateCompatibility), typeof(TemplateAttribution), typeof(string), typeof(string), typeof(IReadOnlyList), typeof(string), typeof(TemplateRole?), typeof(string)]) ]; diff --git a/Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs b/Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs new file mode 100644 index 00000000..2bf7e320 --- /dev/null +++ b/Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Scene.Model.Elements; +using Cratis.Scene.Model.Guards; +using Cratis.Scene.Model.Interactions; + +namespace Cratis.Scene.Model.for_SceneModelShape; + +public class when_round_tripping_a_guarded_action : Specification +{ + const string StageProperties = """ + {"label":"Approve","alternatives":[{"command":"Approve","condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Pending","enumeration":["Pending","Approved"]},"right":{"kind":"comparison","left":{"subject":"item.amount"},"operator":"GreaterThan","right":0}},"arguments":{"id":{"subject":"item.id"}}}],"item":{"kind":"componentProperty","componentId":"orders","componentPropertyPath":"selectedItem","path":"selectedItem"},"otherwise":{"outcome":"Hidden"},"navigateToScreen":"detail","navigateByParameter":"id"} + """; + readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); + GuardedActionProperties _properties = null!; + GuardedActionProperties _roundTrip = null!; + JsonElement _interaction; + + void Establish() + { + var bag = JsonSerializer.Deserialize>(StageProperties, _options)!; + _properties = GuardedActionProperties.Read(new ExternalComponent { Id = "approve", Name = "approve", ComponentName = GuardedActionProperties.ComponentName, Properties = bag }); + } + + void Because() + { + _roundTrip = GuardedActionProperties.Read(new ExternalComponent { Id = "approve", Name = "approve", ComponentName = GuardedActionProperties.ComponentName, Properties = _properties.ToProperties() }); + var binding = new InteractionBinding(new BuiltInInteractionTrigger(InteractionTriggerKind.Click), [], Alternatives: [new(_properties.Alternatives[0].Condition, [])], Otherwise: []); + _interaction = JsonSerializer.SerializeToElement(binding, _options); + } + + [Fact] void should_preserve_the_command() => _roundTrip.Alternatives[0].Command.ShouldEqual("Approve"); + [Fact] void should_preserve_the_subject_binding() => _roundTrip.Item!.ComponentId.ShouldEqual("orders"); + [Fact] void should_preserve_explicit_arguments() => _roundTrip.Alternatives[0].Arguments!["id"].Subject.ShouldEqual("item.id"); + [Fact] void should_preserve_the_nested_guard() => _roundTrip.Alternatives[0].Condition.ShouldBeOfExactType(); + [Fact] void should_preserve_interaction_branches() => _interaction.GetProperty("alternatives").GetArrayLength().ShouldEqual(1); + [Fact] void should_preserve_an_empty_fallback() => _interaction.GetProperty("otherwise").GetArrayLength().ShouldEqual(0); +} diff --git a/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs b/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs new file mode 100644 index 00000000..f5df4364 --- /dev/null +++ b/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Guards; + +/// +/// Compares a subject field with a scalar literal. +/// +/// The subject field. +/// The comparison. +/// A string, number, boolean or null literal. +/// The member names, in ordinal order. +public record ComparisonGuardCondition(SubjectReference Left, GuardOperator Operator, object? Right, IReadOnlyList? Enumeration = null) : GuardCondition; diff --git a/Source/DotNET/Model/Guards/GuardCondition.cs b/Source/DotNET/Model/Guards/GuardCondition.cs new file mode 100644 index 00000000..0b24d96c --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardCondition.cs @@ -0,0 +1,14 @@ +// 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.Json.Serialization; + +namespace Cratis.Scene.Model.Guards; + +/// +/// A structured predicate over the selected item. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(ComparisonGuardCondition), "comparison")] +[JsonDerivedType(typeof(LogicalGuardCondition), "logical")] +public abstract record GuardCondition; diff --git a/Source/DotNET/Model/Guards/GuardOperator.cs b/Source/DotNET/Model/Guards/GuardOperator.cs new file mode 100644 index 00000000..3a232f93 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardOperator.cs @@ -0,0 +1,37 @@ +// 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.Json.Serialization; + +namespace Cratis.Scene.Model.Guards; + +/// +/// The supported literal comparisons. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GuardOperator +{ + /// Equal values. + Equal = 0, + + /// Different non-null values. + NotEqual = 1, + + /// Numeric greater than. + GreaterThan = 2, + + /// Numeric greater than or equal. + GreaterThanOrEqual = 3, + + /// Numeric less than. + LessThan = 4, + + /// Numeric less than or equal. + LessThanOrEqual = 5, + + /// String or array containment. + Contains = 6, + + /// String prefix. + StartsWith = 7 +} diff --git a/Source/DotNET/Model/Guards/GuardedActionAlternative.cs b/Source/DotNET/Model/Guards/GuardedActionAlternative.cs new file mode 100644 index 00000000..f4f1af86 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionAlternative.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Guards; + +/// +/// One ordered guarded command choice. +/// +/// The command to execute. +/// The predicate selecting this command. +/// Explicit subject input bindings. +public record GuardedActionAlternative(string Command, GuardCondition Condition, IReadOnlyDictionary? Arguments = null); diff --git a/Source/DotNET/Model/Guards/GuardedActionFallback.cs b/Source/DotNET/Model/Guards/GuardedActionFallback.cs new file mode 100644 index 00000000..647aa031 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionFallback.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Guards; + +/// +/// The action used when no alternative matches. +/// +/// Whether to hide or execute. +/// The fallback command, when executable. +/// Explicit subject input bindings. +public record GuardedActionFallback(GuardedActionFallbackOutcome Outcome, string? Command = null, IReadOnlyDictionary? Arguments = null); diff --git a/Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs b/Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs new file mode 100644 index 00000000..1efc7d35 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs @@ -0,0 +1,23 @@ +// 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.Json.Serialization; + +namespace Cratis.Scene.Model.Guards; + +/// +/// What an action does when no alternative matches. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GuardedActionFallbackOutcome +{ + /// + /// Hide the action. + /// + Hidden = 0, + + /// + /// Execute the fallback command. + /// + Execute = 1 +} diff --git a/Source/DotNET/Model/Guards/GuardedActionProperties.cs b/Source/DotNET/Model/Guards/GuardedActionProperties.cs new file mode 100644 index 00000000..3fd57c96 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionProperties.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 System.Text.Json; +using System.Text.Json.Serialization; +using Cratis.Scene.Model.Common; +using Cratis.Scene.Model.Elements; + +namespace Cratis.Scene.Model.Guards; + +/// +/// Typed access to a guarded action's open property bag. +/// +/// The button label. +/// The ordered command choices. +/// The selected-item binding. +/// The fallback; absent means hidden. +/// The screen opened after success. +/// The item field passed to navigation. +public record GuardedActionProperties( + string Label, + IReadOnlyList Alternatives, + BindingExpression? Item = null, + GuardedActionFallback? Otherwise = null, + string? NavigateToScreen = null, + string? NavigateByParameter = null) +{ + /// + /// The registered core component name. + /// + public const string ComponentName = "core:guarded-action"; + + static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + /// + /// Reads properties serialized by Stage or constructed in memory. + /// + /// The component carrying the properties. + /// The typed properties. + public static GuardedActionProperties Read(ExternalComponent element) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(element.Properties, _options), _options)!; + + /// + /// Writes the typed properties into an open property bag. + /// + /// The component properties. + public IReadOnlyDictionary ToProperties() => + JsonSerializer.Deserialize>(JsonSerializer.Serialize(this, _options), _options)!; +} diff --git a/Source/DotNET/Model/Guards/LogicalGuardCondition.cs b/Source/DotNET/Model/Guards/LogicalGuardCondition.cs new file mode 100644 index 00000000..8ca16e40 --- /dev/null +++ b/Source/DotNET/Model/Guards/LogicalGuardCondition.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Guards; + +/// +/// Combines two structured predicates. +/// +/// The first predicate. +/// The logical operation. +/// The second predicate. +public record LogicalGuardCondition(GuardCondition Left, LogicalGuardOperator Operator, GuardCondition Right) : GuardCondition; diff --git a/Source/DotNET/Model/Guards/LogicalGuardOperator.cs b/Source/DotNET/Model/Guards/LogicalGuardOperator.cs new file mode 100644 index 00000000..d847b94d --- /dev/null +++ b/Source/DotNET/Model/Guards/LogicalGuardOperator.cs @@ -0,0 +1,23 @@ +// 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.Json.Serialization; + +namespace Cratis.Scene.Model.Guards; + +/// +/// How two predicates combine. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LogicalGuardOperator +{ + /// + /// Both predicates must match. + /// + And = 0, + + /// + /// Either predicate must match. + /// + Or = 1 +} diff --git a/Source/DotNET/Model/Guards/SubjectReference.cs b/Source/DotNET/Model/Guards/SubjectReference.cs new file mode 100644 index 00000000..6383de3e --- /dev/null +++ b/Source/DotNET/Model/Guards/SubjectReference.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Guards; + +/// +/// A path starting with item., resolved against the selected subject. +/// +/// The subject path. +public record SubjectReference(string Subject); diff --git a/Source/DotNET/Model/Interactions/InteractionAlternative.cs b/Source/DotNET/Model/Interactions/InteractionAlternative.cs new file mode 100644 index 00000000..cccbfb2d --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionAlternative.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Guards; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// One whole action list selected by a predicate. +/// +/// The predicate selecting the branch. +/// The actions to run in order. +public record InteractionAlternative(GuardCondition Condition, IReadOnlyList Actions); diff --git a/Source/DotNET/Model/Interactions/InteractionBinding.cs b/Source/DotNET/Model/Interactions/InteractionBinding.cs index 76022af3..6099fe54 100644 --- a/Source/DotNET/Model/Interactions/InteractionBinding.cs +++ b/Source/DotNET/Model/Interactions/InteractionBinding.cs @@ -1,6 +1,7 @@ // 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.Json.Serialization; using Cratis.Scene.Model.Common; namespace Cratis.Scene.Model.Interactions; @@ -11,7 +12,24 @@ namespace Cratis.Scene.Model.Interactions; /// What starts it. /// The actions to run, in declared order. /// The guard that has to hold for the actions to run, when there is one. +/// Ordered first-match branches; Actions is empty when these are supplied. +/// The action list used when no alternative matches. +[method: JsonConstructor] public record InteractionBinding( InteractionTrigger Trigger, IReadOnlyList Actions, - BindingExpression? Condition = null); + BindingExpression? Condition = null, + IReadOnlyList? Alternatives = null, + IReadOnlyList? Otherwise = null) +{ + /// + /// Preserves the constructor used by earlier packages. + /// + /// What starts it. + /// The actions to run. + /// The existing binding guard. + public InteractionBinding(InteractionTrigger trigger, IReadOnlyList actions, BindingExpression? condition) + : this(trigger, actions, condition, null, null) + { + } +} diff --git a/Source/JavaScript/model/guards/ComparisonGuardCondition.ts b/Source/JavaScript/model/guards/ComparisonGuardCondition.ts new file mode 100644 index 00000000..a4c11bed --- /dev/null +++ b/Source/JavaScript/model/guards/ComparisonGuardCondition.ts @@ -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. + +import { GuardOperator } from './GuardOperator'; +import { SubjectReference } from './SubjectReference'; + +export interface ComparisonGuardCondition { + kind: 'comparison'; + left: SubjectReference; + operator: GuardOperator; + right: string | number | boolean | null; + enumeration?: string[]; +} + +export const ComparisonGuardConditionPropertyNames: (keyof ComparisonGuardCondition)[] = ['left', 'operator', 'right', 'enumeration']; diff --git a/Source/JavaScript/model/guards/GuardCondition.ts b/Source/JavaScript/model/guards/GuardCondition.ts new file mode 100644 index 00000000..ba5a7017 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardCondition.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ComparisonGuardCondition } from './ComparisonGuardCondition'; +import { LogicalGuardCondition } from './LogicalGuardCondition'; + +export type GuardCondition = ComparisonGuardCondition | LogicalGuardCondition; diff --git a/Source/JavaScript/model/guards/GuardOperator.ts b/Source/JavaScript/model/guards/GuardOperator.ts new file mode 100644 index 00000000..0e123eef --- /dev/null +++ b/Source/JavaScript/model/guards/GuardOperator.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export enum GuardOperator { + Equal = 'Equal', + NotEqual = 'NotEqual', + GreaterThan = 'GreaterThan', + GreaterThanOrEqual = 'GreaterThanOrEqual', + LessThan = 'LessThan', + LessThanOrEqual = 'LessThanOrEqual', + Contains = 'Contains', + StartsWith = 'StartsWith', +} diff --git a/Source/JavaScript/model/guards/GuardedActionAlternative.ts b/Source/JavaScript/model/guards/GuardedActionAlternative.ts new file mode 100644 index 00000000..2c1e269d --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionAlternative.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GuardCondition } from './GuardCondition'; +import { SubjectReference } from './SubjectReference'; + +export interface GuardedActionAlternative { + command: string; + condition: GuardCondition; + arguments?: Record; +} + +export const GuardedActionAlternativePropertyNames: (keyof GuardedActionAlternative)[] = ['command', 'condition', 'arguments']; diff --git a/Source/JavaScript/model/guards/GuardedActionFallback.ts b/Source/JavaScript/model/guards/GuardedActionFallback.ts new file mode 100644 index 00000000..1684bd56 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionFallback.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GuardedActionFallbackOutcome } from './GuardedActionFallbackOutcome'; +import { SubjectReference } from './SubjectReference'; + +export interface GuardedActionFallback { + outcome: GuardedActionFallbackOutcome; + command?: string; + arguments?: Record; +} + +export const GuardedActionFallbackPropertyNames: (keyof GuardedActionFallback)[] = ['outcome', 'command', 'arguments']; diff --git a/Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts b/Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts new file mode 100644 index 00000000..a02cbc00 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export enum GuardedActionFallbackOutcome { + Hidden = 'Hidden', + Execute = 'Execute', +} diff --git a/Source/JavaScript/model/guards/GuardedActionProperties.ts b/Source/JavaScript/model/guards/GuardedActionProperties.ts new file mode 100644 index 00000000..00c7f8e1 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionProperties.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression } from '../common'; +import { GuardedActionAlternative } from './GuardedActionAlternative'; +import { GuardedActionFallback } from './GuardedActionFallback'; + +export const GuardedActionComponentName = 'core:guarded-action'; + +export interface GuardedActionProperties { + label: string; + alternatives: GuardedActionAlternative[]; + item?: BindingExpression; + otherwise?: GuardedActionFallback; + navigateToScreen?: string; + navigateByParameter?: string; +} + +export const GuardedActionPropertiesPropertyNames: (keyof GuardedActionProperties)[] = [ + 'label', 'alternatives', 'item', 'otherwise', 'navigateToScreen', 'navigateByParameter', +]; diff --git a/Source/JavaScript/model/guards/LogicalGuardCondition.ts b/Source/JavaScript/model/guards/LogicalGuardCondition.ts new file mode 100644 index 00000000..92d4a7ec --- /dev/null +++ b/Source/JavaScript/model/guards/LogicalGuardCondition.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GuardCondition } from './GuardCondition'; +import { LogicalGuardOperator } from './LogicalGuardOperator'; + +export interface LogicalGuardCondition { + kind: 'logical'; + left: GuardCondition; + operator: LogicalGuardOperator; + right: GuardCondition; +} + +export const LogicalGuardConditionPropertyNames: (keyof LogicalGuardCondition)[] = ['left', 'operator', 'right']; diff --git a/Source/JavaScript/model/guards/LogicalGuardOperator.ts b/Source/JavaScript/model/guards/LogicalGuardOperator.ts new file mode 100644 index 00000000..de05c676 --- /dev/null +++ b/Source/JavaScript/model/guards/LogicalGuardOperator.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export enum LogicalGuardOperator { + And = 'And', + Or = 'Or', +} diff --git a/Source/JavaScript/model/guards/SubjectReference.ts b/Source/JavaScript/model/guards/SubjectReference.ts new file mode 100644 index 00000000..98d0b2c5 --- /dev/null +++ b/Source/JavaScript/model/guards/SubjectReference.ts @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export interface SubjectReference { + subject: string; +} + +export const SubjectReferencePropertyNames: (keyof SubjectReference)[] = ['subject']; diff --git a/Source/JavaScript/model/guards/index.ts b/Source/JavaScript/model/guards/index.ts new file mode 100644 index 00000000..8d7e1811 --- /dev/null +++ b/Source/JavaScript/model/guards/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './GuardCondition'; +export * from './ComparisonGuardCondition'; +export * from './LogicalGuardCondition'; +export * from './SubjectReference'; +export * from './GuardOperator'; +export * from './LogicalGuardOperator'; +export * from './GuardedActionAlternative'; +export * from './GuardedActionFallback'; +export * from './GuardedActionFallbackOutcome'; +export * from './GuardedActionProperties'; diff --git a/Source/JavaScript/model/index.ts b/Source/JavaScript/model/index.ts index e3c8c4dd..676e8e6c 100644 --- a/Source/JavaScript/model/index.ts +++ b/Source/JavaScript/model/index.ts @@ -12,6 +12,7 @@ export * from './icons'; export * from './packages'; export * from './sampleData'; export * from './interactions'; +export * from './guards'; export * from './screens'; export * from './starters'; export * from './queries'; diff --git a/Source/JavaScript/model/interactions/InteractionAlternative.ts b/Source/JavaScript/model/interactions/InteractionAlternative.ts new file mode 100644 index 00000000..f2b5c238 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionAlternative.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GuardCondition } from '../guards'; +import { InteractionAction } from './InteractionAction'; + +export interface InteractionAlternative { + condition: GuardCondition; + actions: InteractionAction[]; +} + +export const InteractionAlternativePropertyNames: (keyof InteractionAlternative)[] = ['condition', 'actions']; diff --git a/Source/JavaScript/model/interactions/InteractionBinding.ts b/Source/JavaScript/model/interactions/InteractionBinding.ts index b6d79e14..00aa6e52 100644 --- a/Source/JavaScript/model/interactions/InteractionBinding.ts +++ b/Source/JavaScript/model/interactions/InteractionBinding.ts @@ -3,6 +3,7 @@ import { BindingExpression } from '../common'; import { InteractionAction } from './InteractionAction'; +import { InteractionAlternative } from './InteractionAlternative'; import { InteractionTrigger } from './InteractionTrigger'; /** @@ -12,6 +13,8 @@ export interface InteractionBinding { trigger: InteractionTrigger; actions: InteractionAction[]; condition?: BindingExpression; + alternatives?: InteractionAlternative[]; + otherwise?: InteractionAction[]; } -export const InteractionBindingPropertyNames: (keyof InteractionBinding)[] = ['trigger', 'actions', 'condition']; +export const InteractionBindingPropertyNames: (keyof InteractionBinding)[] = ['trigger', 'actions', 'condition', 'alternatives', 'otherwise']; diff --git a/Source/JavaScript/model/interactions/index.ts b/Source/JavaScript/model/interactions/index.ts index 52466a35..f9fb0504 100644 --- a/Source/JavaScript/model/interactions/index.ts +++ b/Source/JavaScript/model/interactions/index.ts @@ -23,4 +23,5 @@ export * from './NotifyAction'; export * from './ConfirmAction'; export * from './RaiseTriggerAction'; export * from './InteractionBinding'; +export * from './InteractionAlternative'; export * from './Behavior'; diff --git a/scene-model-shape.json b/scene-model-shape.json index e9677d9c..6cc70483 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -1,6 +1,14 @@ { "description": "Shared shape contract between Cratis.Scene.Model (C#, source of truth) and @cratis/scene.model (the hand-maintained TypeScript mirror). Both a C# spec (Model.Specs/for_SceneModelShape) and a TypeScript spec (model/for_shape) assert their own side's reflected/declared shape against this file independently, so a desync on either side fails its own build rather than only the other one's.", "types": { + "GuardCondition": [], + "SubjectReference": ["subject"], + "ComparisonGuardCondition": ["left", "operator", "right", "enumeration"], + "LogicalGuardCondition": ["left", "operator", "right"], + "GuardedActionAlternative": ["command", "condition", "arguments"], + "GuardedActionFallback": ["outcome", "command", "arguments"], + "GuardedActionProperties": ["label", "alternatives", "item", "otherwise", "navigateToScreen", "navigateByParameter"], + "InteractionAlternative": ["condition", "actions"], "Color": [ "red", "green", @@ -386,7 +394,9 @@ "InteractionBinding": [ "trigger", "actions", - "condition" + "condition", + "alternatives", + "otherwise" ], "InteractionTrigger": [], "BuiltInInteractionTrigger": [ @@ -691,6 +701,9 @@ ] }, "enums": { + "GuardOperator": ["Equal", "NotEqual", "GreaterThan", "GreaterThanOrEqual", "LessThan", "LessThanOrEqual", "Contains", "StartsWith"], + "LogicalGuardOperator": ["And", "Or"], + "GuardedActionFallbackOutcome": ["Hidden", "Execute"], "TemplateScope": [ "Application", "Module", From 8487faade18e689fcb5bd6afa554ecf63705bbdf Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 18:26:25 +0100 Subject: [PATCH 2/8] Evaluate guarded selection identically in C# and TypeScript --- .../when_running_the_shared_corpus.cs | 54 ++++++++ .../DotNET/Engine/Guards/GuardConditions.cs | 130 ++++++++++++++++++ .../DotNET/Engine/Guards/GuardedSelection.cs | 50 +++++++ .../when_round_tripping_stage_properties.cs} | 8 +- ...nning_against_the_shared_fixture_corpus.ts | 33 +++++ .../engine/guards/evaluateGuardCondition.ts | 79 +++++++++++ Source/JavaScript/engine/guards/index.ts | 5 + .../engine/guards/selectGuardedAlternative.ts | 24 ++++ Source/JavaScript/engine/index.ts | 1 + guarded-selection-fixtures.json | 65 +++++++++ 10 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs create mode 100644 Source/DotNET/Engine/Guards/GuardConditions.cs create mode 100644 Source/DotNET/Engine/Guards/GuardedSelection.cs rename Source/DotNET/Model.Specs/{for_SceneModelShape/when_round_tripping_a_guarded_action.cs => for_GuardedActionProperties/when_round_tripping_stage_properties.cs} (84%) create mode 100644 Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts create mode 100644 Source/JavaScript/engine/guards/evaluateGuardCondition.ts create mode 100644 Source/JavaScript/engine/guards/index.ts create mode 100644 Source/JavaScript/engine/guards/selectGuardedAlternative.ts create mode 100644 guarded-selection-fixtures.json diff --git a/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs new file mode 100644 index 00000000..e2d041f9 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs @@ -0,0 +1,54 @@ +// 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.Json; +using Cratis.Scene.Engine.Guards; + +namespace Cratis.Scene.Engine.for_GuardedSelection; + +public class when_running_the_shared_corpus : Specification +{ + JsonElement _corpus; + + void Establish() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) directory = directory.Parent; + _corpus = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "guarded-selection-fixtures.json"))).RootElement; + } + + [Fact] + void should_evaluate_every_shared_predicate() + { + foreach (var entry in _corpus.GetProperty("cases").EnumerateArray()) + { + var condition = entry.GetProperty("condition"); + GuardConditions.CanEvaluate(condition).ShouldEqual(entry.GetProperty("evaluable").GetBoolean()); + var expected = entry.GetProperty("result"); + GuardConditions.Evaluate(condition, entry.GetProperty("item")).ShouldEqual(expected.ValueKind == JsonValueKind.Null ? null : expected.GetBoolean()); + } + } + + [Fact] + void should_select_every_shared_interaction() + { + foreach (var entry in _corpus.GetProperty("interactions").EnumerateArray()) + { + var selection = GuardedSelection.SelectInteraction(entry.GetProperty("alternatives"), entry.GetProperty("item"), entry.GetProperty("hasOtherwise").GetBoolean()); + selection.Kind.ShouldEqual(entry.GetProperty("expected").GetProperty("kind").GetString()); + } + } + + [Fact] + void should_select_every_shared_branch() + { + foreach (var entry in _corpus.GetProperty("selections").EnumerateArray()) + { + entry.TryGetProperty("otherwise", out var otherwise); + var selected = GuardedSelection.SelectAction(entry.GetProperty("alternatives"), entry.GetProperty("item"), otherwise); + var expected = entry.GetProperty("expected"); + selected.Kind.ShouldEqual(expected.GetProperty("kind").GetString()); + selected.Index.ShouldEqual(expected.TryGetProperty("index", out var index) ? index.GetInt32() : null); + } + } +} diff --git a/Source/DotNET/Engine/Guards/GuardConditions.cs b/Source/DotNET/Engine/Guards/GuardConditions.cs new file mode 100644 index 00000000..872902be --- /dev/null +++ b/Source/DotNET/Engine/Guards/GuardConditions.cs @@ -0,0 +1,130 @@ +// 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.Json; +using Cratis.Scene.Model.Guards; + +namespace Cratis.Scene.Engine.Guards; + +/// +/// Evaluates the closed guard vocabulary without executing authored code. +/// +public static class GuardConditions +{ + static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; + static readonly HashSet _forbidden = ["__proto__", "prototype", "constructor"]; + + /// + /// Evaluates a typed guard. Null means the shape is unsupported. + /// + /// The guard. + /// The selected subject. + /// Whether it holds, or null when unsupported. + public static bool? Evaluate(GuardCondition condition, object? item) => Evaluate(JsonSerializer.SerializeToElement(condition, _options), JsonSerializer.SerializeToElement(item, _options)); + + /// + /// Determines whether every node uses the supported guard vocabulary. + /// + /// The wire predicate. + /// Whether it is understood. + public static bool CanEvaluate(JsonElement condition) + { + var kind = Text(Get(condition, "kind")); + var operation = Text(Get(condition, "operator")); + var left = Get(condition, "left"); + var right = Get(condition, "right"); + if (kind == "logical") return (operation == "And" || operation == "Or") && CanEvaluate(left) && CanEvaluate(right); + if (kind != "comparison" || !ValidPath(Text(Get(left, "subject"))) || !Scalar(right)) return false; + if (!Enum.TryParse(operation, out var parsed) || !Enum.IsDefined(parsed) || operation != parsed.ToString()) return false; + var enumeration = Get(condition, "enumeration"); + if (enumeration.ValueKind != JsonValueKind.Undefined && (enumeration.ValueKind != JsonValueKind.Array || enumeration.GetArrayLength() == 0 || enumeration.EnumerateArray().Any(value => value.ValueKind != JsonValueKind.String) || enumeration.EnumerateArray().Select(value => value.GetString()).Distinct().Count() != enumeration.GetArrayLength())) return false; + if (parsed is GuardOperator.GreaterThan or GuardOperator.GreaterThanOrEqual or GuardOperator.LessThan or GuardOperator.LessThanOrEqual) return right.ValueKind == JsonValueKind.Number; + return parsed != GuardOperator.StartsWith || right.ValueKind == JsonValueKind.String; + } + + /// + /// Evaluates a wire predicate against a wire subject. + /// + /// The wire predicate. + /// The selected subject. + /// Whether it holds, or null when unsupported. + public static bool? Evaluate(JsonElement condition, JsonElement item) + { + if (!CanEvaluate(condition)) return null; + var operation = Text(Get(condition, "operator")); + if (Text(Get(condition, "kind")) == "comparison") return Compare(condition, item, operation); + var left = Evaluate(Get(condition, "left"), item) == true; + var right = Evaluate(Get(condition, "right"), item) == true; + return operation == "And" ? left && right : left || right; + } + + static bool ValidPath(string path) => path.StartsWith("item.", StringComparison.Ordinal) && path[5..].Split('.').All(part => part.Length > 0 && (char.IsAsciiLetter(part[0]) || part[0] == '_') && part.All(character => char.IsAsciiLetterOrDigit(character) || character == '_') && !_forbidden.Contains(part)); + + static JsonElement Resolve(JsonElement reference, JsonElement item) + { + var path = Text(Get(reference, "subject")); + if (!ValidPath(path)) return default; + foreach (var part in path[5..].Split('.')) item = Get(item, part); + return item; + } + + static bool Compare(JsonElement condition, JsonElement item, string operation) + { + var enumeration = Get(condition, "enumeration"); + var left = Normalize(Resolve(Get(condition, "left"), item), enumeration); + var right = Normalize(Get(condition, "right"), enumeration); + if (left.ValueKind == JsonValueKind.Undefined || right.ValueKind == JsonValueKind.Undefined) return false; + if (left.ValueKind == JsonValueKind.Null || right.ValueKind == JsonValueKind.Null) return operation == "Equal" && left.ValueKind == JsonValueKind.Null && right.ValueKind == JsonValueKind.Null; + return operation switch + { + "Equal" => Same(left, right), + "NotEqual" => Scalar(left) && Compatible(left, right) && !Same(left, right), + "Contains" => Contains(left, right), + "StartsWith" => left.ValueKind == JsonValueKind.String && right.ValueKind == JsonValueKind.String && left.GetString()!.StartsWith(right.GetString()!, StringComparison.Ordinal), + _ => Numeric(left, right, operation) + }; + } + + static JsonElement Normalize(JsonElement value, JsonElement enumeration) + { + if (enumeration.ValueKind != JsonValueKind.Array || value.ValueKind == JsonValueKind.Null) return value; + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && number == Math.Truncate(number) && number >= 0 && number < enumeration.GetArrayLength()) return enumeration[(int)number]; + if (value.ValueKind == JsonValueKind.String) return enumeration.EnumerateArray().FirstOrDefault(member => string.Equals(member.GetString(), value.GetString(), StringComparison.OrdinalIgnoreCase)); + return default; + } + + static bool Numeric(JsonElement left, JsonElement right, string operation) + { + if (left.ValueKind != JsonValueKind.Number || right.ValueKind != JsonValueKind.Number) return false; + var first = left.GetDouble(); + var second = right.GetDouble(); + return operation switch + { + "GreaterThan" => first > second, + "GreaterThanOrEqual" => first >= second, + "LessThan" => first < second, + "LessThanOrEqual" => first <= second, + _ => false + }; + } + + static bool Contains(JsonElement left, JsonElement right) => left.ValueKind switch + { + JsonValueKind.String => right.ValueKind == JsonValueKind.String && left.GetString()!.Contains(right.GetString()!, StringComparison.Ordinal), + JsonValueKind.Array => left.EnumerateArray().Any(value => Same(value, right)), + _ => false + }; + + static bool Same(JsonElement left, JsonElement right) => Compatible(left, right) && left.ValueKind switch + { + JsonValueKind.String => left.GetString() == right.GetString(), + JsonValueKind.Number => left.GetDouble() == right.GetDouble(), + JsonValueKind.True or JsonValueKind.False => left.GetBoolean() == right.GetBoolean(), + _ => false + }; + + static bool Compatible(JsonElement left, JsonElement right) => left.ValueKind == right.ValueKind || (left.ValueKind is JsonValueKind.True or JsonValueKind.False && right.ValueKind is JsonValueKind.True or JsonValueKind.False); + static bool Scalar(JsonElement value) => value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null; + static string Text(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString()! : string.Empty; + static JsonElement Get(JsonElement value, string property) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(property, out var found) ? found : default; +} diff --git a/Source/DotNET/Engine/Guards/GuardedSelection.cs b/Source/DotNET/Engine/Guards/GuardedSelection.cs new file mode 100644 index 00000000..b130c33f --- /dev/null +++ b/Source/DotNET/Engine/Guards/GuardedSelection.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 System.Text.Json; + +namespace Cratis.Scene.Engine.Guards; + +/// +/// The selected branch or its fail-closed disposition. +/// +/// Alternative, otherwise, hidden, noSubject or unevaluable, in wire casing. +/// The first matching alternative's zero-based index. +public record GuardedSelection(string Kind, int? Index = null) +{ + /// + /// Chooses exactly one ordered action branch. + /// + /// The alternatives containing conditions. + /// The selected subject. + /// The fallback object. + /// A branch index or fallback disposition. + public static GuardedSelection SelectAction(JsonElement alternatives, JsonElement item, JsonElement otherwise = default) + { + var selection = SelectInteraction(alternatives, item, Text(Get(otherwise, "outcome")) == "Execute"); + return selection.Kind == "unmatched" ? new("hidden") : selection; + } + + /// + /// Chooses one whole interaction branch, without executing it. + /// + /// The alternatives containing conditions. + /// The gesture subject. + /// Whether an otherwise action list exists. + /// A branch index or no-match disposition. + public static GuardedSelection SelectInteraction(JsonElement alternatives, JsonElement item, bool hasOtherwise = false) + { + if (item.ValueKind != JsonValueKind.Object) return new("noSubject"); + if (alternatives.ValueKind != JsonValueKind.Array || alternatives.EnumerateArray().Any(alternative => !GuardConditions.CanEvaluate(Get(alternative, "condition")))) return new("unevaluable"); + var index = 0; + foreach (var alternative in alternatives.EnumerateArray()) + { + if (GuardConditions.Evaluate(Get(alternative, "condition"), item) == true) return new("alternative", index); + index++; + } + return new(hasOtherwise ? "otherwise" : "unmatched"); + } + + static string Text(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString()! : string.Empty; + static JsonElement Get(JsonElement value, string property) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(property, out var found) ? found : default; +} diff --git a/Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs b/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs similarity index 84% rename from Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs rename to Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs index 2bf7e320..05825058 100644 --- a/Source/DotNET/Model.Specs/for_SceneModelShape/when_round_tripping_a_guarded_action.cs +++ b/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs @@ -6,12 +6,12 @@ using Cratis.Scene.Model.Guards; using Cratis.Scene.Model.Interactions; -namespace Cratis.Scene.Model.for_SceneModelShape; +namespace Cratis.Scene.Model.for_GuardedActionProperties; -public class when_round_tripping_a_guarded_action : Specification +public class when_round_tripping_stage_properties : Specification { const string StageProperties = """ - {"label":"Approve","alternatives":[{"command":"Approve","condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Pending","enumeration":["Pending","Approved"]},"right":{"kind":"comparison","left":{"subject":"item.amount"},"operator":"GreaterThan","right":0}},"arguments":{"id":{"subject":"item.id"}}}],"item":{"kind":"componentProperty","componentId":"orders","componentPropertyPath":"selectedItem","path":"selectedItem"},"otherwise":{"outcome":"Hidden"},"navigateToScreen":"detail","navigateByParameter":"id"} + {"label":"Approve","alternatives":[{"command":"Approve","condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Pending","enumeration":["Pending","Approved"]},"right":{"kind":"comparison","left":{"subject":"item.amount"},"operator":"GreaterThan","right":0}},"arguments":{"id":{"subject":"item.id"}}}],"otherwise":{"outcome":"Hidden"},"navigateToScreen":"detail","navigateByParameter":"id"} """; readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); GuardedActionProperties _properties = null!; @@ -32,7 +32,7 @@ void Because() } [Fact] void should_preserve_the_command() => _roundTrip.Alternatives[0].Command.ShouldEqual("Approve"); - [Fact] void should_preserve_the_subject_binding() => _roundTrip.Item!.ComponentId.ShouldEqual("orders"); + [Fact] void should_accept_stage_properties_without_a_subject_binding() => _roundTrip.Item.ShouldBeNull(); [Fact] void should_preserve_explicit_arguments() => _roundTrip.Alternatives[0].Arguments!["id"].Subject.ShouldEqual("item.id"); [Fact] void should_preserve_the_nested_guard() => _roundTrip.Alternatives[0].Condition.ShouldBeOfExactType(); [Fact] void should_preserve_interaction_branches() => _interaction.GetProperty("alternatives").GetArrayLength().ShouldEqual(1); diff --git a/Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts new file mode 100644 index 00000000..c439f5b1 --- /dev/null +++ b/Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { GuardedActionFallback } from '@cratis/scene.model'; +import { canEvaluateGuard, evaluateGuard, GuardSelection, selectGuardedAction, selectGuardedInteraction } from '../guards'; + +interface Corpus { + interactions: { name: string; alternatives: { condition: unknown }[]; item: unknown; hasOtherwise: boolean; expected: GuardSelection }[]; + cases: { name: string; condition: unknown; item: unknown; evaluable: boolean; result: boolean | null }[]; + selections: { name: string; alternatives: { condition: unknown }[]; item: unknown; otherwise?: GuardedActionFallback; expected: GuardSelection }[]; +} +const corpus: Corpus = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../guarded-selection-fixtures.json'), 'utf8')); + +describe('when evaluating the shared guard corpus', () => { + for (const entry of corpus.cases) { + it(`should evaluate ${entry.name}`, () => { + expect(canEvaluateGuard(entry.condition)).toBe(entry.evaluable); + expect(evaluateGuard(entry.condition, entry.item) ?? null).toBe(entry.result); + }); + } + for (const entry of corpus.interactions) { + it(`should select interaction ${entry.name}`, () => { + expect(selectGuardedInteraction(entry.alternatives, entry.item, entry.hasOtherwise)).toEqual(entry.expected); + }); + } + for (const entry of corpus.selections) { + it(`should select ${entry.name}`, () => { + expect(selectGuardedAction(entry.alternatives, entry.item, entry.otherwise)).toEqual(entry.expected); + }); + } +}); diff --git a/Source/JavaScript/engine/guards/evaluateGuardCondition.ts b/Source/JavaScript/engine/guards/evaluateGuardCondition.ts new file mode 100644 index 00000000..74522b73 --- /dev/null +++ b/Source/JavaScript/engine/guards/evaluateGuardCondition.ts @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { SubjectReference } from '@cratis/scene.model'; + +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); +const operators = new Set(['Equal', 'NotEqual', 'GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual', 'Contains', 'StartsWith']); +const numericOperators = new Set(['GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual']); + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function scalar(value: unknown): value is string | number | boolean | null { + return value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)); +} + +function subjectPath(reference: unknown): string[] | undefined { + if (!record(reference) || typeof reference.subject !== 'string' || !reference.subject.startsWith('item.')) return undefined; + const parts = reference.subject.slice(5).split('.'); + return parts.every(part => /^[A-Za-z_][A-Za-z0-9_]*$/.test(part) && !forbidden.has(part)) ? parts : undefined; +} + +export function resolveSubjectReference(reference: SubjectReference, item: unknown): unknown { + const parts = subjectPath(reference); + if (!parts) return undefined; + let value = item; + for (const part of parts) { + if (!record(value) || !Object.hasOwn(value, part)) return undefined; + value = value[part]; + } + return value; +} + +export function canEvaluateGuard(condition: unknown): boolean { + if (!record(condition)) return false; + if (condition.kind === 'logical') { + return (condition.operator === 'And' || condition.operator === 'Or') && canEvaluateGuard(condition.left) && canEvaluateGuard(condition.right); + } + if (condition.kind !== 'comparison' || !subjectPath(condition.left) || typeof condition.operator !== 'string' || !operators.has(condition.operator) || !scalar(condition.right)) return false; + const enumeration = condition.enumeration; + if (enumeration !== undefined && (!Array.isArray(enumeration) || enumeration.length === 0 || !enumeration.every(value => typeof value === 'string') || new Set(enumeration).size !== enumeration.length)) return false; + if (numericOperators.has(condition.operator) && typeof condition.right !== 'number') return false; + if (condition.operator === 'StartsWith' && typeof condition.right !== 'string') return false; + return true; +} + +function normalize(value: unknown, enumeration: unknown): unknown { + if (!Array.isArray(enumeration) || value === null) return value; + if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < enumeration.length) return enumeration[value]; + if (typeof value === 'string') return enumeration.find(member => member.toLowerCase() === value.toLowerCase()); + return undefined; +} + +function compare(condition: Record, item: unknown): boolean { + const left = normalize(resolveSubjectReference(condition.left as SubjectReference, item), condition.enumeration); + const right = normalize(condition.right, condition.enumeration); + if (left === undefined || right === undefined) return false; + if (left === null || right === null) return condition.operator === 'Equal' && left === null && right === null; + switch (condition.operator) { + case 'Equal': return scalar(left) && left === right; + case 'NotEqual': return scalar(left) && typeof left === typeof right && left !== right; + case 'GreaterThan': return typeof left === 'number' && typeof right === 'number' && left > right; + case 'GreaterThanOrEqual': return typeof left === 'number' && typeof right === 'number' && left >= right; + case 'LessThan': return typeof left === 'number' && typeof right === 'number' && left < right; + case 'LessThanOrEqual': return typeof left === 'number' && typeof right === 'number' && left <= right; + case 'Contains': return typeof left === 'string' ? typeof right === 'string' && left.includes(right) : Array.isArray(left) && left.some(value => scalar(value) && value === right); + case 'StartsWith': return typeof left === 'string' && left.startsWith(right as string); + default: return false; + } +} + +export function evaluateGuard(condition: unknown, item: unknown): boolean | undefined { + if (!canEvaluateGuard(condition) || !record(condition)) return undefined; + if (condition.kind === 'comparison') return compare(condition, item); + const left = evaluateGuard(condition.left, item) === true; + const right = evaluateGuard(condition.right, item) === true; + return condition.operator === 'And' ? left && right : left || right; +} diff --git a/Source/JavaScript/engine/guards/index.ts b/Source/JavaScript/engine/guards/index.ts new file mode 100644 index 00000000..81c7b58c --- /dev/null +++ b/Source/JavaScript/engine/guards/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './evaluateGuardCondition'; +export * from './selectGuardedAlternative'; diff --git a/Source/JavaScript/engine/guards/selectGuardedAlternative.ts b/Source/JavaScript/engine/guards/selectGuardedAlternative.ts new file mode 100644 index 00000000..785fc16b --- /dev/null +++ b/Source/JavaScript/engine/guards/selectGuardedAlternative.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GuardedActionFallbackOutcome } from '@cratis/scene.model'; +import { canEvaluateGuard, evaluateGuard } from './evaluateGuardCondition'; + +export type GuardSelection = { kind: 'alternative'; index: number } | { kind: 'otherwise' | 'hidden' | 'noSubject' | 'unevaluable' | 'unmatched' }; + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function selectGuardedInteraction(alternatives: readonly unknown[], item: unknown, hasOtherwise = false): GuardSelection { + if (!record(item)) return { kind: 'noSubject' }; + if (alternatives.some(alternative => !record(alternative) || !canEvaluateGuard(alternative.condition))) return { kind: 'unevaluable' }; + const index = alternatives.findIndex(alternative => record(alternative) && evaluateGuard(alternative.condition, item) === true); + if (index >= 0) return { kind: 'alternative', index }; + return { kind: hasOtherwise ? 'otherwise' : 'unmatched' }; +} + +export function selectGuardedAction(alternatives: readonly unknown[], item: unknown, otherwise?: { outcome: GuardedActionFallbackOutcome }): GuardSelection { + const result = selectGuardedInteraction(alternatives, item, otherwise?.outcome === GuardedActionFallbackOutcome.Execute); + return result.kind === 'unmatched' ? { kind: 'hidden' } : result; +} diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 24dde189..06cc7c0e 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -39,6 +39,7 @@ export * from './buildStarterProfile'; export * from './incompatibleStarterThemes'; export * from './ActionDispatcher'; export * from './runActions'; +export * from './guards'; export * from './resolveBehaviors'; export * from './resolveStrings'; export * from './navigation'; diff --git a/guarded-selection-fixtures.json b/guarded-selection-fixtures.json new file mode 100644 index 00000000..557ae203 --- /dev/null +++ b/guarded-selection-fixtures.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "stage": { + "componentName": "core:action", + "properties": { + "label": "Approve", + "alternatives": [ + {"command": "ApproveOrder", "condition": {"kind": "comparison", "left": {"subject": "item.status"}, "operator": "Equal", "right": "Pending", "enumeration": ["Pending", "Approved"]}, "arguments": {"orderId": {"subject": "item.id"}}} + ], + "otherwise": {"outcome": "Hidden"}, + "navigateToScreen": "details", + "navigateByParameter": "id" + } + }, + "cases": [ + {"name":"string equality", "condition":{"kind":"comparison","left":{"subject":"item.name"},"operator":"Equal","right":"A"}, "item":{"name":"A"}, "evaluable":true,"result":true}, + {"name":"numeric equality", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2}, "item":{"value":2.0}, "evaluable":true,"result":true}, + {"name":"boolean equality", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":false}, "item":{"value":false}, "evaluable":true,"result":true}, + {"name":"strict types", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2}, "item":{"value":"2"}, "evaluable":true,"result":false}, + {"name":"inequality", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"NotEqual","right":true}, "item":{"value":false}, "evaluable":true,"result":true}, + {"name":"strict inequality", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"NotEqual","right":2}, "item":{"value":"2"}, "evaluable":true,"result":false}, + {"name":"greater", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"GreaterThan","right":2}, "item":{"value":3}, "evaluable":true,"result":true}, + {"name":"greater equal", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"GreaterThanOrEqual","right":2}, "item":{"value":2}, "evaluable":true,"result":true}, + {"name":"less", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"LessThan","right":2}, "item":{"value":1}, "evaluable":true,"result":true}, + {"name":"less equal", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"LessThanOrEqual","right":2}, "item":{"value":2}, "evaluable":true,"result":true}, + {"name":"numeric left does not coerce", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"LessThan","right":2}, "item":{"value":"1"}, "evaluable":true,"result":false}, + {"name":"contains string", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Contains","right":"pen"}, "item":{"value":"pending"}, "evaluable":true,"result":true}, + {"name":"contains array", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Contains","right":2}, "item":{"value":[1,2,3]}, "evaluable":true,"result":true}, + {"name":"contains array strict", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Contains","right":2}, "item":{"value":["2"]}, "evaluable":true,"result":false}, + {"name":"starts with", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"StartsWith","right":"pen"}, "item":{"value":"pending"}, "evaluable":true,"result":true}, + {"name":"case sensitive", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"StartsWith","right":"Pen"}, "item":{"value":"pending"}, "evaluable":true,"result":false}, + {"name":"nested path", "condition":{"kind":"comparison","left":{"subject":"item.customer.name"},"operator":"Equal","right":"A"}, "item":{"customer":{"name":"A"}}, "evaluable":true,"result":true}, + {"name":"missing field", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"NotEqual","right":2}, "item":{}, "evaluable":true,"result":false}, + {"name":"null field", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":null}, "item":{"value":null}, "evaluable":true,"result":true}, + {"name":"unknown operator", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Execute","right":2}, "item":{"value":2}, "evaluable":false,"result":null}, + {"name":"unknown node", "condition":{"kind":"script","source":"true"}, "item":{}, "evaluable":false,"result":null}, + {"name":"unusable subject reference", "condition":{"kind":"comparison","left":{"subject":"window.value"},"operator":"Equal","right":2}, "item":{"value":2}, "evaluable":false,"result":null}, + {"name":"prototype path", "condition":{"kind":"comparison","left":{"subject":"item.__proto__.value"},"operator":"Equal","right":2}, "item":{}, "evaluable":false,"result":null}, + {"name":"empty path segment", "condition":{"kind":"comparison","left":{"subject":"item..value"},"operator":"Equal","right":2}, "item":{}, "evaluable":false,"result":null}, + {"name":"bad numeric literal", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"GreaterThan","right":"2"}, "item":{"value":3}, "evaluable":false,"result":null}, + {"name":"enum name", "condition":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Approved","enumeration":["Pending","Approved"]}, "item":{"status":"Approved"}, "evaluable":true,"result":true}, + {"name":"enum ignores case", "condition":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"approved","enumeration":["Pending","Approved"]}, "item":{"status":"APPROVED"}, "evaluable":true,"result":true}, + {"name":"present null inequality", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"NotEqual","right":2}, "item":{"value":null}, "evaluable":true,"result":false}, + {"name":"missing versus null", "condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":null}, "item":{}, "evaluable":true,"result":false}, + {"name":"enum ordinal", "condition":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Approved","enumeration":["Pending","Approved"]}, "item":{"status":1}, "evaluable":true,"result":true}, + {"name":"enum invalid ordinal", "condition":{"kind":"comparison","left":{"subject":"item.status"},"operator":"NotEqual","right":"Approved","enumeration":["Pending","Approved"]}, "item":{"status":-1}, "evaluable":true,"result":false}, + {"name":"enum invalid name", "condition":{"kind":"comparison","left":{"subject":"item.status"},"operator":"NotEqual","right":"Approved","enumeration":["Pending","Approved"]}, "item":{"status":"Unknown"}, "evaluable":true,"result":false}, + {"name":"and", "condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2},"right":{"kind":"comparison","left":{"subject":"item.value"},"operator":"GreaterThan","right":1}}, "item":{"value":2}, "evaluable":true,"result":true}, + {"name":"or", "condition":{"kind":"logical","operator":"Or","left":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":1},"right":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2}}, "item":{"value":2}, "evaluable":true,"result":true}, + {"name":"unknown in unselected logical branch", "condition":{"kind":"logical","operator":"Or","left":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2},"right":{"kind":"script"}}, "item":{"value":2}, "evaluable":false,"result":null} + ], + "interactions": [ + {"name":"unmatched without otherwise", "item":{}, "alternatives":[],"hasOtherwise":false,"expected":{"kind":"unmatched"}}, + {"name":"unmatched with otherwise", "item":{}, "alternatives":[],"hasOtherwise":true,"expected":{"kind":"otherwise"}}, + {"name":"no subject with otherwise", "item":null, "alternatives":[],"hasOtherwise":true,"expected":{"kind":"noSubject"}} + ], + "selections": [ + {"name":"first match", "item":{"value":2}, "alternatives":[{"condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2}},{"condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"GreaterThan","right":1}}],"expected":{"kind":"alternative","index":0}}, + {"name":"hidden by default", "item":{"value":1}, "alternatives":[{"condition":{"kind":"comparison","left":{"subject":"item.value"},"operator":"Equal","right":2}}],"expected":{"kind":"hidden"}}, + {"name":"hidden fallback", "item":{}, "alternatives":[],"otherwise":{"outcome":"Hidden"},"expected":{"kind":"hidden"}}, + {"name":"execute fallback", "item":{}, "alternatives":[],"otherwise":{"outcome":"Execute","command":"Fallback"},"expected":{"kind":"otherwise"}}, + {"name":"unknown cannot select fallback", "item":{}, "alternatives":[{"condition":{"kind":"script"}}],"otherwise":{"outcome":"Execute","command":"Fallback"},"expected":{"kind":"unevaluable"}}, + {"name":"missing subject cannot select fallback", "item":null, "alternatives":[],"otherwise":{"outcome":"Execute","command":"Fallback"},"expected":{"kind":"noSubject"}} + ] +} From 9fc04b905695d9cacf8b1ad6cacd44dfff682673 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 19:03:07 +0100 Subject: [PATCH 3/8] Run guarded interaction alternatives with first-match semantics --- .../DotNET/Engine/Guards/GuardConditions.cs | 56 +++++++++--- .../when_selecting_a_guarded_branch.ts | 64 ++++++++++++++ .../engine/guards/evaluateGuardCondition.ts | 88 +++++++++++++------ Source/JavaScript/engine/runActions.ts | 21 ++++- .../when_selecting_a_guarded_interaction.tsx | 48 ++++++++++ .../react/interactions/useInteractions.ts | 12 ++- interaction-fixtures.json | 5 ++ 7 files changed, 252 insertions(+), 42 deletions(-) create mode 100644 Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts create mode 100644 Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx diff --git a/Source/DotNET/Engine/Guards/GuardConditions.cs b/Source/DotNET/Engine/Guards/GuardConditions.cs index 872902be..0761289c 100644 --- a/Source/DotNET/Engine/Guards/GuardConditions.cs +++ b/Source/DotNET/Engine/Guards/GuardConditions.cs @@ -34,12 +34,8 @@ public static bool CanEvaluate(JsonElement condition) var left = Get(condition, "left"); var right = Get(condition, "right"); if (kind == "logical") return (operation == "And" || operation == "Or") && CanEvaluate(left) && CanEvaluate(right); - if (kind != "comparison" || !ValidPath(Text(Get(left, "subject"))) || !Scalar(right)) return false; - if (!Enum.TryParse(operation, out var parsed) || !Enum.IsDefined(parsed) || operation != parsed.ToString()) return false; - var enumeration = Get(condition, "enumeration"); - if (enumeration.ValueKind != JsonValueKind.Undefined && (enumeration.ValueKind != JsonValueKind.Array || enumeration.GetArrayLength() == 0 || enumeration.EnumerateArray().Any(value => value.ValueKind != JsonValueKind.String) || enumeration.EnumerateArray().Select(value => value.GetString()).Distinct().Count() != enumeration.GetArrayLength())) return false; - if (parsed is GuardOperator.GreaterThan or GuardOperator.GreaterThanOrEqual or GuardOperator.LessThan or GuardOperator.LessThanOrEqual) return right.ValueKind == JsonValueKind.Number; - return parsed != GuardOperator.StartsWith || right.ValueKind == JsonValueKind.String; + if (kind != "comparison") return false; + return ValidComparison(condition, operation); } /// @@ -58,6 +54,29 @@ public static bool CanEvaluate(JsonElement condition) return operation == "And" ? left && right : left || right; } + static bool ValidComparison(JsonElement condition, string operation) + { + if (!ValidPath(Text(Get(Get(condition, "left"), "subject")))) return false; + if (!Enum.TryParse(operation, out var parsed)) return false; + if (!Enum.IsDefined(parsed) || operation != parsed.ToString()) return false; + return ValidEnumeration(Get(condition, "enumeration")) && ValidLiteral(parsed, Get(condition, "right")); + } + + static bool ValidEnumeration(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Undefined) return true; + if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() == 0) return false; + if (value.EnumerateArray().Any(member => member.ValueKind != JsonValueKind.String)) return false; + return value.EnumerateArray().Select(member => member.GetString()).Distinct().Count() == value.GetArrayLength(); + } + + static bool ValidLiteral(GuardOperator operation, JsonElement right) + { + if (!Scalar(right)) return false; + if (operation is GuardOperator.GreaterThan or GuardOperator.GreaterThanOrEqual or GuardOperator.LessThan or GuardOperator.LessThanOrEqual) return right.ValueKind == JsonValueKind.Number; + return operation != GuardOperator.StartsWith || right.ValueKind == JsonValueKind.String; + } + static bool ValidPath(string path) => path.StartsWith("item.", StringComparison.Ordinal) && path[5..].Split('.').All(part => part.Length > 0 && (char.IsAsciiLetter(part[0]) || part[0] == '_') && part.All(character => char.IsAsciiLetterOrDigit(character) || character == '_') && !_forbidden.Contains(part)); static JsonElement Resolve(JsonElement reference, JsonElement item) @@ -75,7 +94,10 @@ static bool Compare(JsonElement condition, JsonElement item, string operation) var right = Normalize(Get(condition, "right"), enumeration); if (left.ValueKind == JsonValueKind.Undefined || right.ValueKind == JsonValueKind.Undefined) return false; if (left.ValueKind == JsonValueKind.Null || right.ValueKind == JsonValueKind.Null) return operation == "Equal" && left.ValueKind == JsonValueKind.Null && right.ValueKind == JsonValueKind.Null; - return operation switch + return CompareValues(left, right, operation); + } + + static bool CompareValues(JsonElement left, JsonElement right, string operation) => operation switch { "Equal" => Same(left, right), "NotEqual" => Scalar(left) && Compatible(left, right) && !Same(left, right), @@ -83,22 +105,35 @@ static bool Compare(JsonElement condition, JsonElement item, string operation) "StartsWith" => left.ValueKind == JsonValueKind.String && right.ValueKind == JsonValueKind.String && left.GetString()!.StartsWith(right.GetString()!, StringComparison.Ordinal), _ => Numeric(left, right, operation) }; - } static JsonElement Normalize(JsonElement value, JsonElement enumeration) { if (enumeration.ValueKind != JsonValueKind.Array || value.ValueKind == JsonValueKind.Null) return value; - if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && number == Math.Truncate(number) && number >= 0 && number < enumeration.GetArrayLength()) return enumeration[(int)number]; + if (Ordinal(value, enumeration.GetArrayLength(), out var ordinal)) return enumeration[ordinal]; if (value.ValueKind == JsonValueKind.String) return enumeration.EnumerateArray().FirstOrDefault(member => string.Equals(member.GetString(), value.GetString(), StringComparison.OrdinalIgnoreCase)); return default; } + static bool Ordinal(JsonElement value, int length, out int ordinal) + { + ordinal = 0; + if (value.ValueKind != JsonValueKind.Number) return false; + var number = value.GetDouble(); + if (number != Math.Truncate(number)) return false; + if (number < 0 || number >= length) return false; + ordinal = (int)number; + return true; + } + static bool Numeric(JsonElement left, JsonElement right, string operation) { if (left.ValueKind != JsonValueKind.Number || right.ValueKind != JsonValueKind.Number) return false; var first = left.GetDouble(); var second = right.GetDouble(); - return operation switch + return CompareNumbers(first, second, operation); + } + + static bool CompareNumbers(double first, double second, string operation) => operation switch { "GreaterThan" => first > second, "GreaterThanOrEqual" => first >= second, @@ -106,7 +141,6 @@ static bool Numeric(JsonElement left, JsonElement right, string operation) "LessThanOrEqual" => first <= second, _ => false }; - } static bool Contains(JsonElement left, JsonElement right) => left.ValueKind switch { diff --git a/Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts b/Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts new file mode 100644 index 00000000..c71de284 --- /dev/null +++ b/Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { GuardCondition, GuardOperator, InteractionBinding, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model'; +import { runBindings } from '../runActions'; +import { RecordingDispatcher } from './given/a_recording_dispatcher'; + +const condition: GuardCondition = { kind: 'comparison', left: { subject: 'item.id' }, operator: GuardOperator.Equal, right: 1 }; +const command = (name: string) => ({ kind: InteractionActionKind.ExecuteCommand as const, command: name }); +const binding = (overrides: Partial = {}): InteractionBinding => ({ + trigger: { kind: InteractionTriggerKind.Click }, actions: [], + alternatives: [{ condition, actions: [command('first'), command('second')] }, { condition, actions: [command('never')] }], + otherwise: [command('fallback')], ...overrides, +}); + +async function run(value: InteractionBinding, subject: unknown = { id: 1 }, succeeds = true) { + const dispatcher = new RecordingDispatcher(); + dispatcher.commandSucceeds = succeeds; + let reads = 0; + const result = await runBindings([{ level: 'test', behaviorName: 'test', binding: value }], dispatcher, { resolve: () => undefined, subject: () => { reads++; return subject; } }); + return { dispatcher, result, reads }; +} + +const corpus: { guardedBindingCases: { name: string; subject: unknown; binding: InteractionBinding; expectedCalls: string[] }[] } = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../interaction-fixtures.json'), 'utf8')); + +describe('when selecting a guarded interaction branch', () => { + for (const entry of corpus.guardedBindingCases) { + it(`should satisfy shared case: ${entry.name}`, async () => { + expect((await run(entry.binding, entry.subject)).dispatcher.calls).toEqual(entry.expectedCalls); + }); + } + it('should select once and run one entire action list', async () => { + const { dispatcher, reads } = await run(binding()); + expect(dispatcher.calls).toEqual(['executeCommand:first:{}', 'executeCommand:second:{}']); + expect(reads).toBe(1); + }); + it('should run otherwise only after an evaluable no-match', async () => { + expect((await run(binding(), { id: 2 })).dispatcher.calls).toEqual(['executeCommand:fallback:{}']); + }); + it('should run nothing on no match without otherwise', async () => { + expect((await run(binding({ otherwise: undefined }), { id: 2 })).dispatcher.calls).toEqual([]); + }); + it('should run nothing without a subject, even with otherwise', async () => { + expect((await run(binding(), null)).dispatcher.calls).toEqual([]); + }); + it('should refuse unknown guards instead of running otherwise', async () => { + const result = await run(binding({ alternatives: [{ condition: { kind: 'future' } as unknown as GuardCondition, actions: [command('never')] }] })); + expect(result.dispatcher.calls).toEqual([]); + expect(result.result.findings[0].kind).toBe('unevaluableCondition'); + }); + it('should reject ambiguous actions and alternatives', async () => { + const result = await run(binding({ actions: [command('ambiguous')] })); + expect(result.dispatcher.calls).toEqual([]); + expect(result.result.findings[0].detail).toContain('SCENE-GUARD-001'); + }); + it('should not fall through after a failed selected command', async () => { + expect((await run(binding(), { id: 1 }, false)).dispatcher.calls).toEqual(['executeCommand:first:{}', 'executeCommand:second:{}']); + }); + it('should leave ordinary unguarded interactions unchanged', async () => { + expect((await run(binding({ alternatives: undefined, actions: [command('ordinary')] }))).dispatcher.calls).toEqual(['executeCommand:ordinary:{}']); + }); +}); diff --git a/Source/JavaScript/engine/guards/evaluateGuardCondition.ts b/Source/JavaScript/engine/guards/evaluateGuardCondition.ts index 74522b73..ef35b3e0 100644 --- a/Source/JavaScript/engine/guards/evaluateGuardCondition.ts +++ b/Source/JavaScript/engine/guards/evaluateGuardCondition.ts @@ -4,21 +4,48 @@ import { SubjectReference } from '@cratis/scene.model'; const forbidden = new Set(['__proto__', 'prototype', 'constructor']); -const operators = new Set(['Equal', 'NotEqual', 'GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual', 'Contains', 'StartsWith']); const numericOperators = new Set(['GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual']); +type Comparison = (left: unknown, right: unknown) => boolean; + +function numbers(left: unknown, right: unknown, compare: (first: number, second: number) => boolean): boolean { + if (typeof left !== 'number' || typeof right !== 'number') return false; + return compare(left, right); +} + +function contains(left: unknown, right: unknown): boolean { + if (typeof left === 'string') return typeof right === 'string' && left.includes(right); + return Array.isArray(left) && left.some(value => scalar(value) && value === right); +} + +const comparisons: Record = { + Equal: (left, right) => scalar(left) && left === right, + NotEqual: (left, right) => scalar(left) && typeof left === typeof right && left !== right, + GreaterThan: (left, right) => numbers(left, right, (first, second) => first > second), + GreaterThanOrEqual: (left, right) => numbers(left, right, (first, second) => first >= second), + LessThan: (left, right) => numbers(left, right, (first, second) => first < second), + LessThanOrEqual: (left, right) => numbers(left, right, (first, second) => first <= second), + Contains: contains, + StartsWith: (left, right) => typeof left === 'string' && typeof right === 'string' && left.startsWith(right), +}; function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function scalar(value: unknown): value is string | number | boolean | null { - return value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)); + return value === null || ['string', 'boolean'].includes(typeof value) || (typeof value === 'number' && Number.isFinite(value)); +} + +function safeSegment(part: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(part) && !forbidden.has(part); } function subjectPath(reference: unknown): string[] | undefined { - if (!record(reference) || typeof reference.subject !== 'string' || !reference.subject.startsWith('item.')) return undefined; + if (!record(reference)) return undefined; + if (typeof reference.subject !== 'string') return undefined; + if (!reference.subject.startsWith('item.')) return undefined; const parts = reference.subject.slice(5).split('.'); - return parts.every(part => /^[A-Za-z_][A-Za-z0-9_]*$/.test(part) && !forbidden.has(part)) ? parts : undefined; + return parts.every(safeSegment) ? parts : undefined; } export function resolveSubjectReference(reference: SubjectReference, item: unknown): unknown { @@ -32,22 +59,41 @@ export function resolveSubjectReference(reference: SubjectReference, item: unkno return value; } +function validEnumeration(value: unknown): boolean { + if (value === undefined) return true; + if (!Array.isArray(value) || value.length === 0) return false; + return value.every(member => typeof member === 'string') && new Set(value).size === value.length; +} + +function validLiteral(operator: string, right: unknown): boolean { + if (!scalar(right)) return false; + if (numericOperators.has(operator)) return typeof right === 'number'; + return operator !== 'StartsWith' || typeof right === 'string'; +} + +function validComparison(condition: Record): boolean { + if (!subjectPath(condition.left)) return false; + if (typeof condition.operator !== 'string') return false; + if (!Object.hasOwn(comparisons, condition.operator)) return false; + return validEnumeration(condition.enumeration) && validLiteral(condition.operator, condition.right); +} + export function canEvaluateGuard(condition: unknown): boolean { if (!record(condition)) return false; - if (condition.kind === 'logical') { - return (condition.operator === 'And' || condition.operator === 'Or') && canEvaluateGuard(condition.left) && canEvaluateGuard(condition.right); - } - if (condition.kind !== 'comparison' || !subjectPath(condition.left) || typeof condition.operator !== 'string' || !operators.has(condition.operator) || !scalar(condition.right)) return false; - const enumeration = condition.enumeration; - if (enumeration !== undefined && (!Array.isArray(enumeration) || enumeration.length === 0 || !enumeration.every(value => typeof value === 'string') || new Set(enumeration).size !== enumeration.length)) return false; - if (numericOperators.has(condition.operator) && typeof condition.right !== 'number') return false; - if (condition.operator === 'StartsWith' && typeof condition.right !== 'string') return false; - return true; + if (condition.kind === 'comparison') return validComparison(condition); + if (condition.kind !== 'logical') return false; + if (condition.operator !== 'And' && condition.operator !== 'Or') return false; + return canEvaluateGuard(condition.left) && canEvaluateGuard(condition.right); +} + +function ordinal(value: unknown, length: number): value is number { + if (typeof value !== 'number' || !Number.isInteger(value)) return false; + return value >= 0 && value < length; } function normalize(value: unknown, enumeration: unknown): unknown { if (!Array.isArray(enumeration) || value === null) return value; - if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < enumeration.length) return enumeration[value]; + if (ordinal(value, enumeration.length)) return enumeration[value]; if (typeof value === 'string') return enumeration.find(member => member.toLowerCase() === value.toLowerCase()); return undefined; } @@ -56,18 +102,8 @@ function compare(condition: Record, item: unknown): boolean { const left = normalize(resolveSubjectReference(condition.left as SubjectReference, item), condition.enumeration); const right = normalize(condition.right, condition.enumeration); if (left === undefined || right === undefined) return false; - if (left === null || right === null) return condition.operator === 'Equal' && left === null && right === null; - switch (condition.operator) { - case 'Equal': return scalar(left) && left === right; - case 'NotEqual': return scalar(left) && typeof left === typeof right && left !== right; - case 'GreaterThan': return typeof left === 'number' && typeof right === 'number' && left > right; - case 'GreaterThanOrEqual': return typeof left === 'number' && typeof right === 'number' && left >= right; - case 'LessThan': return typeof left === 'number' && typeof right === 'number' && left < right; - case 'LessThanOrEqual': return typeof left === 'number' && typeof right === 'number' && left <= right; - case 'Contains': return typeof left === 'string' ? typeof right === 'string' && left.includes(right) : Array.isArray(left) && left.some(value => scalar(value) && value === right); - case 'StartsWith': return typeof left === 'string' && left.startsWith(right as string); - default: return false; - } + if (left === null || right === null) return condition.operator === 'Equal' && left === right; + return comparisons[condition.operator as string](left, right); } export function evaluateGuard(condition: unknown, item: unknown): boolean | undefined { diff --git a/Source/JavaScript/engine/runActions.ts b/Source/JavaScript/engine/runActions.ts index 1205a4a2..94d11197 100644 --- a/Source/JavaScript/engine/runActions.ts +++ b/Source/JavaScript/engine/runActions.ts @@ -20,6 +20,7 @@ import { import { ActionDispatcher } from './ActionDispatcher'; import { BindingResolver } from './BindingResolver'; +import { selectGuardedInteraction } from './guards'; /** * Why an interaction stopped before running everything it was given. @@ -45,7 +46,7 @@ export enum InteractionStop { * application, and 'this application produced no findings' has to be checkable rather than a matter of opinion. */ export interface InteractionFinding { - kind: 'unknownAction' | 'unresolvedMessage' | 'unresolvedCondition'; + kind: 'unknownAction' | 'unresolvedMessage' | 'unresolvedCondition' | 'unevaluableCondition'; detail: string; } @@ -64,6 +65,7 @@ export interface InteractionRun { */ export interface InteractionContext { resolve: BindingResolver; + subject?: () => unknown; /** Looks up a localization key. Returns the key itself when the host has no catalogue for it. */ localize?: (key: string) => string; } @@ -258,7 +260,9 @@ export async function runBindings( for (const candidate of bindings) { if (!applies(candidate.binding, context, run)) continue; - await runSequence(candidate.binding.actions ?? [], dispatcher, context, run); + const actions = branchActions(candidate.binding, context, run); + if (!actions) continue; + await runSequence(actions, dispatcher, context, run); // A navigation or a declined confirm ends the interaction, not merely the binding that caused it. // The screen the later bindings were attached to is gone, or the gate they sat behind was refused. @@ -268,6 +272,19 @@ export async function runBindings( return run; } +function branchActions(binding: InteractionBinding, context: InteractionContext, run: InteractionRun): InteractionAction[] | undefined { + if (binding.alternatives === undefined) return binding.actions ?? []; + if ((binding.actions?.length ?? 0) > 0) { + run.findings.push({ kind: 'unevaluableCondition', detail: 'SCENE-GUARD-001: An interaction cannot combine actions and alternatives' }); + return undefined; + } + const selected = selectGuardedInteraction(binding.alternatives, context.subject?.() ?? context.resolve({ path: 'item' }), binding.otherwise !== undefined); + if (selected.kind === 'alternative') return binding.alternatives[selected.index].actions; + if (selected.kind === 'otherwise') return binding.otherwise; + if (selected.kind === 'unevaluable') run.findings.push({ kind: 'unevaluableCondition', detail: 'SCENE-GUARD-001: The structured guard could not be resolved' }); + return undefined; +} + function applies(binding: InteractionBinding, context: InteractionContext, run: InteractionRun): boolean { if (!binding.condition) return true; diff --git a/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx b/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx new file mode 100644 index 00000000..22a09fea --- /dev/null +++ b/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Behavior, ExecuteCommandAction, GuardOperator, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model'; +import { RecordingDispatcher } from '../../engine/for_runActions/given/a_recording_dispatcher'; +import { InteractionScope, useInteractions } from '../interactions'; +import { RenderBindingScopeProvider } from '../renderer/RenderBindingScopeContext'; + +const behavior: Behavior = { + bindings: [{ + trigger: { kind: InteractionTriggerKind.Click }, actions: [], + alternatives: [{ + condition: { kind: 'comparison', left: { subject: 'item.id' }, operator: GuardOperator.Equal, right: 1 }, + actions: [{ kind: InteractionActionKind.ExecuteCommand, command: 'Selected' } as ExecuteCommandAction], + }], + otherwise: [{ kind: InteractionActionKind.ExecuteCommand, command: 'Fallback' } as ExecuteCommandAction], + }], +}; + +function Button() { + return ; +} + +describe('when selecting a guarded interaction', () => { + it('should use the current render subject for the gesture', async () => { + const dispatcher = new RecordingDispatcher(); + render( + + + ; +} + +function actionLabel(label: string): string { + return label || 'Action'; +} + +function actionDisabled(pending: boolean, command: string | undefined): boolean { + return pending || !command; +} + +function diagnosticTitle(unsupported: boolean): string | undefined { + return unsupported ? 'SCENE-GUARD-001: This action\'s guard could not be resolved.' : undefined; +} + +function resolveItem(value: unknown, resolve: (binding: BindingExpression) => unknown): unknown { + if (value === null || typeof value !== 'object') return value; + return typeof (value as BindingExpression).path === 'string' ? resolve(value as BindingExpression) : value; +} + +function ActionStatus({ unsupported, error }: { unsupported: boolean; error: string }) { + if (unsupported) return SCENE-GUARD-001: This action's guard could not be resolved.; + return error ? {error} : null; +} + +function resolveArguments(bindings: Record = {}, item: unknown): Record { + return Object.fromEntries(Object.entries(bindings).map(([name, reference]) => { + const value = resolveSubjectReference(reference, item); + if (value === undefined || value === null) throw new Error(`The argument '${name}' could not be resolved.`); + return [name, value]; + })); +} + +function raiseCommand(selected: CommandSelection, args: Record): void { + const { command, element, item, properties } = selected; + window.dispatchEvent(new CustomEvent('cratis.scene.command', { detail: { command, element, arguments: args, item, navigateToScreen: properties.navigateToScreen, navigateByParameter: properties.navigateByParameter } })); +} + +async function navigateAfterSuccess(properties: GuardedActionProperties, item: unknown, dispatcher: ActionDispatcher): Promise { + if (!properties.navigateToScreen) return; + await dispatcher.navigate(properties.navigateToScreen, navigationParameters(properties.navigateByParameter, item)); +} + +function navigationParameters(parameter: string | undefined, item: unknown): Record { + const navigation: Record = {}; + if (parameter) { + const value = resolveSubjectReference({ subject: `item.${parameter}` }, item); + if (value === undefined || value === null) throw new Error('The navigation parameter could not be resolved.'); + navigation[parameter] = value; + } + return navigation; +} diff --git a/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx b/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx index fe8a20c7..de75f533 100644 --- a/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx +++ b/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx @@ -4,6 +4,7 @@ import { resolveArgumentSource } from '@cratis/scene.engine'; import type { ExternalComponent } from '@cratis/scene.model'; import { RegisteredComponentProps, useRenderBindingScope } from '../renderer'; +import { CoreGuardedAction } from './CoreGuardedAction'; function textProperty(properties: Record, name: string, fallback = ''): string { return typeof properties[name] === 'string' ? properties[name] : fallback; @@ -37,7 +38,12 @@ interface ArgumentMapping { * An action that names no label is labelled with its command read as a sentence (`Change basket quantity`), and is * drawn as a button of the default blueprint's toolbar look (`scene-toolbar-button`) rather than as bare text. */ -export function CoreAction({ element }: RegisteredComponentProps) { +export function CoreAction(props: RegisteredComponentProps) { + if ('alternatives' in props.element.properties) return ; + return ; +} + +function CoreCommandAction({ element }: RegisteredComponentProps) { const command = textProperty(element.properties, 'command'); const label = textProperty(element.properties, 'label', commandLabel(command)); const scope = useRenderBindingScope(); diff --git a/Source/JavaScript/react/core/coreComponentMetadata.ts b/Source/JavaScript/react/core/coreComponentMetadata.ts index be8c9847..8c0001db 100644 --- a/Source/JavaScript/react/core/coreComponentMetadata.ts +++ b/Source/JavaScript/react/core/coreComponentMetadata.ts @@ -11,6 +11,7 @@ export const coreComponentMetadata: Record = { card: m('Card', ComponentCategory.Layout, 'square', ['panel', 'box', 'container']), data: m('Data', ComponentCategory.DataDisplay, 'database', ['read model', 'query', 'source']), action: m('Command action', ComponentCategory.Actions, 'zap', ['command', 'invoke', 'submit']), + 'guarded-action': m('Guarded command action', ComponentCategory.Actions, 'git-branch', ['command', 'guard', 'condition', 'selected item']), section: m('Section', ComponentCategory.Layout, 'layout-panel-top', ['group', 'area', 'block']), navigate: m('Navigate', ComponentCategory.Navigation, 'corner-down-right', ['link', 'go to', 'route']), title: m('Title', ComponentCategory.DataDisplay, 'heading', ['heading', 'header', 'caption']), diff --git a/Source/JavaScript/react/core/coreComponents.ts b/Source/JavaScript/react/core/coreComponents.ts index 207425fe..9fa52b23 100644 --- a/Source/JavaScript/react/core/coreComponents.ts +++ b/Source/JavaScript/react/core/coreComponents.ts @@ -5,6 +5,7 @@ import { ComponentRegistry } from '../renderer'; import { componentRegistryKey } from '../packages'; import { CoreText } from './CoreText'; import { CoreButton } from './CoreButton'; +import { CoreGuardedAction } from './CoreGuardedAction'; import { CoreCard } from './CoreCard'; import { CoreNavigationBar } from './CoreNavigationBar'; import { CoreToolbar, CoreToolbarItem } from './CoreToolbar'; @@ -34,6 +35,7 @@ export const coreComponents: ComponentRegistry = { [componentRegistryKey('core', 'card')]: CoreCard, [componentRegistryKey('core', 'data')]: CoreData, [componentRegistryKey('core', 'action')]: CoreAction, + [componentRegistryKey('core', 'guarded-action')]: CoreGuardedAction, [componentRegistryKey('core', 'section')]: CoreSection, [componentRegistryKey('core', 'navigate')]: CoreNavigate, [componentRegistryKey('core', 'title')]: CoreTitle, diff --git a/Source/JavaScript/react/core/coreDescriptors.ts b/Source/JavaScript/react/core/coreDescriptors.ts index 8f797647..fa131306 100644 --- a/Source/JavaScript/react/core/coreDescriptors.ts +++ b/Source/JavaScript/react/core/coreDescriptors.ts @@ -46,6 +46,24 @@ const describedCore: ComponentDescriptor[] = [ }, ], }, + { + component: key('guarded-action'), + displayName: 'Guarded command action', + description: 'Runs the first matching command for the selected item. Hidden unless a branch or executable fallback is selected; unsupported guards are refused.', + properties: [ + { path: 'label', label: 'Label', group: 'Content', valueType: PropertyValueType.String }, + { path: 'item', label: 'Selected item', group: 'Data', valueType: PropertyValueType.Object, open: true }, + { path: 'alternatives', label: 'Alternatives', group: 'Behavior', valueType: PropertyValueType.Collection, + item: { label: 'Alternative', positional: true, properties: [ + { path: 'command', label: 'Command', group: 'Behavior', valueType: PropertyValueType.String }, + { path: 'condition', label: 'Condition', group: 'Behavior', valueType: PropertyValueType.Object, open: true }, + { path: 'arguments', label: 'Arguments', group: 'Behavior', valueType: PropertyValueType.Object, open: true }, + ] } }, + { path: 'otherwise', label: 'Fallback', group: 'Behavior', valueType: PropertyValueType.Object, open: true }, + { path: 'navigateToScreen', label: 'Success screen', group: 'Navigation', valueType: PropertyValueType.String }, + { path: 'navigateByParameter', label: 'Navigation parameter', group: 'Navigation', valueType: PropertyValueType.String }, + ], + }, { component: key('toolbarItem'), displayName: 'Toolbar button', diff --git a/Source/JavaScript/react/core/corePackage.ts b/Source/JavaScript/react/core/corePackage.ts index 654b61ae..c4300eb9 100644 --- a/Source/JavaScript/react/core/corePackage.ts +++ b/Source/JavaScript/react/core/corePackage.ts @@ -24,6 +24,7 @@ export const corePackageManifest: ScenePackage = { 'card', 'data', 'action', + 'guarded-action', 'section', 'navigate', 'title', diff --git a/Source/JavaScript/react/core/for_CoreGuardedAction/when_running_a_guarded_command.tsx b/Source/JavaScript/react/core/for_CoreGuardedAction/when_running_a_guarded_command.tsx new file mode 100644 index 00000000..20b61a2f --- /dev/null +++ b/Source/JavaScript/react/core/for_CoreGuardedAction/when_running_a_guarded_command.tsx @@ -0,0 +1,139 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ActionDispatcher, CommandOutcome } from '@cratis/scene.engine'; +import { ExternalComponent, GuardOperator, GuardedActionFallbackOutcome } from '@cratis/scene.model'; +import { CoreGuardedAction } from '../CoreGuardedAction'; +import { CoreAction } from '../ScreenplayCoreComponents'; +import { InteractionScope } from '../../interactions'; +import { RenderBindingScopeProvider } from '../../renderer/RenderBindingScopeContext'; + +const guard = { kind: 'comparison', left: { subject: 'item.status' }, operator: GuardOperator.Equal, right: 'Pending' }; +const alternatives = [{ command: 'Approve', condition: guard, arguments: { orderId: { subject: 'item.id' } } }]; +const item = { id: 42, status: 'Pending', internalValue: 'not-an-argument' }; +const dispatcher = { + executeCommand: vi.fn(), navigate: vi.fn(), navigateBack: vi.fn(), + openDialog: vi.fn(), closeDialog: vi.fn(), refreshQuery: vi.fn(), setState: vi.fn(), notify: vi.fn(), confirm: vi.fn(), raise: vi.fn(), +} satisfies ActionDispatcher; + +function draw(properties: Record = {}, subject: unknown = item, stage = false, scoped = true) { + const element = { id: 'approve', componentName: stage ? 'core:action' : 'core:guarded-action', properties: { label: 'Approve', alternatives, ...properties }, slots: {} } as unknown as ExternalComponent; + const Component = stage ? CoreAction : CoreGuardedAction; + const content = (dataContext: unknown) => { + const button = ; + return scoped ? {button} : button; + }; + const rendered = render(content(subject)); + return { ...rendered, changeSubject: (next: unknown) => rendered.rerender(content(next)) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + dispatcher.executeCommand.mockResolvedValue({ isSuccess: true }); +}); + +describe('when running a guarded command', () => { + it('should re-evaluate when the live subject changes', () => { + const view = draw({}, { ...item, status: 'Approved' }); + expect(screen.queryByRole('button')).toBeNull(); + view.changeSubject(item); + expect(screen.getByRole('button')).toBeTruthy(); + view.changeSubject(null); + expect(screen.queryByRole('button')).toBeNull(); + }); + it('should run only the first matching alternative', async () => { + draw({ alternatives: [...alternatives, { command: 'Never', condition: guard }] }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 })); + }); + it('should submit explicit arguments with the item and navigate only after success', async () => { + draw({ navigateToScreen: 'details', navigateByParameter: 'id' }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.navigate).toHaveBeenCalledWith('details', { id: 42 })); + expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 }); + }); + it('should map the Stage core:action shape to guarded execution', async () => { + draw({}, item, true); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 })); + }); + it('should hide an unmatched action without an executable fallback', () => { + draw({}, { ...item, status: 'Approved' }); + expect(screen.queryByRole('button')).toBeNull(); + }); + it('should hide the action without a subject even when otherwise executes', () => { + draw({ otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback' } }, null); + expect(screen.queryByRole('button')).toBeNull(); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + }); + it('should execute an explicit fallback', async () => { + draw({ otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback', arguments: { id: { subject: 'item.id' } } } }, { ...item, status: 'Approved' }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Fallback', { item: { ...item, status: 'Approved' }, id: 42 })); + }); + it('should refuse legacy string guards and not execute otherwise', () => { + draw({ alternatives: [{ command: 'Never', guard: 'item.status == Pending' }], otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback' } }); + const button = screen.getByRole('button') as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(screen.getByRole('alert').textContent).toContain('SCENE-GUARD-001'); + fireEvent.click(button); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + }); + it('should prevent repeated clicks while a command is pending', async () => { + let complete!: (outcome: CommandOutcome) => void; + dispatcher.executeCommand.mockReturnValue(new Promise(resolve => { complete = resolve; })); + draw(); + const button = screen.getByRole('button') as HTMLButtonElement; + fireEvent.click(button); + fireEvent.click(button); + expect(button.disabled).toBe(true); + expect(dispatcher.executeCommand).toHaveBeenCalledTimes(1); + complete({ isSuccess: true }); + await waitFor(() => expect(button.disabled).toBe(false)); + }); + it('should show failure and never navigate or fall through', async () => { + dispatcher.executeCommand.mockResolvedValue({ isSuccess: false }); + draw({ navigateToScreen: 'details', otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback' } }); + fireEvent.click(screen.getByRole('button')); + await screen.findByRole('alert'); + expect(dispatcher.navigate).not.toHaveBeenCalled(); + expect(dispatcher.executeCommand).toHaveBeenCalledTimes(1); + }); + it('should dispatch the host-neutral event when no interaction scope is present', () => { + const listener = vi.fn(); + window.addEventListener('cratis.scene.command', listener, { once: true }); + draw({ navigateToScreen: 'details', navigateByParameter: 'id' }, item, false, false); + fireEvent.click(screen.getByRole('button')); + expect(listener).toHaveBeenCalledTimes(1); + const detail = (listener.mock.calls[0][0] as CustomEvent).detail; + expect(detail).toMatchObject({ command: 'Approve', arguments: { orderId: 42 }, item, navigateToScreen: 'details', navigateByParameter: 'id' }); + expect(dispatcher.navigate).not.toHaveBeenCalled(); + }); + it('should re-select at click time and not execute a stale choice', () => { + const mutable = { ...item }; + draw({}, mutable); + mutable.status = 'Approved'; + fireEvent.click(screen.getByRole('button')); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + expect(screen.queryByRole('button')).toBeNull(); + }); + it('should accept an item already resolved by the renderer', async () => { + draw({ item }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 })); + }); + it('should end the wait and display thrown command failures', async () => { + dispatcher.executeCommand.mockRejectedValue(new Error('Unavailable')); + draw(); + fireEvent.click(screen.getByRole('button')); + expect((await screen.findByRole('alert')).textContent).toBe('Unavailable'); + expect((screen.getByRole('button') as HTMLButtonElement).disabled).toBe(false); + }); + it('should refuse unresolved explicit arguments', async () => { + draw({ alternatives: [{ command: 'Never', condition: guard, arguments: { id: { subject: 'item.missing' } } }] }); + fireEvent.click(screen.getByRole('button')); + await screen.findByRole('alert'); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts b/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts index f65e237c..24a0b391 100644 --- a/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts +++ b/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts @@ -16,7 +16,7 @@ describe('when describing the core package', () => { it('should give every layout-free component an author configures properties to edit', () => { coreDescriptors.filter(descriptor => descriptor.properties.length > 0).map(descriptor => descriptor.component) - .should.have.members(['core:button', 'core:action', 'core:toolbarItem', 'core:sliceDialog', 'core:column', 'core:outlet', 'core:navigationBar']); + .should.have.members(['core:button', 'core:action', 'core:guarded-action', 'core:toolbarItem', 'core:sliceDialog', 'core:column', 'core:outlet', 'core:navigationBar']); }); it('should describe every component of the package, the structural ones with nothing to edit', () => { @@ -37,6 +37,11 @@ describe('when describing the core package', () => { action.properties[2].valueType.should.equal(PropertyValueType.Collection); }); + it('should describe the guarded action without changing the ordinary action descriptor', () => { + const action = coreDescriptors.find(descriptor => descriptor.component === 'core:guarded-action')!; + action.properties.map(property => property.path).should.deep.equal(['label', 'item', 'alternatives', 'otherwise', 'navigateToScreen', 'navigateByParameter']); + }); + it('should describe a toolbar button as a title, a state change slice, an emphasis and an icon', () => { const button = coreDescriptors.find(descriptor => descriptor.component === 'core:toolbarItem')!; button.properties.map(property => property.path).should.deep.equal(['label', 'slice', 'emphasis', 'icon', 'confirm', 'presentation', 'target']); diff --git a/component-metadata-fixtures.json b/component-metadata-fixtures.json index 10dcadc7..cc64b2c1 100644 --- a/component-metadata-fixtures.json +++ b/component-metadata-fixtures.json @@ -1,6 +1,12 @@ { "description": "Shared component metadata corpus (spec CMP1, PICK2, PICK3, 3.4). It states the rules once as data: which component metadata is a problem, which badge a component gets, which components a picker filter finds and why, and which components 'Frequently used' lists. The TypeScript engine (engine/for_componentMetadata) asserts every case. There is no C# engine twin: component descriptors exist only in the TypeScript model, as the editing diagnostics do.", "validation": [ + { + "name": "the guarded action has the standard actions category", + "descriptors": [{ "component": "core:guarded-action", "category": "Actions", "properties": [] }], + "registered": ["core:guarded-action"], + "expected": [] + }, { "name": "a described component with a known category", "descriptors": [{ "component": "Acme:button", "category": "Actions", "properties": [] }], From 241522f4f7efc5421562c88cf5c59103d403b8e2 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 19:13:11 +0100 Subject: [PATCH 5/8] Document guarded actions and interactions --- Documentation/architecture/guarded-actions.md | 110 ++++++++++++++++++ Documentation/toc.yml | 2 + 2 files changed, 112 insertions(+) create mode 100644 Documentation/architecture/guarded-actions.md diff --git a/Documentation/architecture/guarded-actions.md b/Documentation/architecture/guarded-actions.md new file mode 100644 index 00000000..2016afa9 --- /dev/null +++ b/Documentation/architecture/guarded-actions.md @@ -0,0 +1,110 @@ +# Guarded actions and interactions + +A guarded action chooses the first command whose structured condition matches the current item. Scene renders one button, not one per branch. Selection is a presentation decision, **not authorization**: the host must still validate and authorize the command. + +## Guarded command action + +Use `core:guarded-action`. Existing Stage documents using `core:action` with an `alternatives` property are mapped to the same component. An ordinary `core:action` with `command` is unchanged. + +The open `properties` bag has this shape: + +```json +{ + "label": "Approve", + "item": { + "kind": "componentProperty", + "path": "selectedItem", + "componentId": "orders", + "componentPropertyPath": "selectedItem" + }, + "alternatives": [ + { + "command": "ApproveOrder", + "condition": { + "kind": "comparison", + "left": { "subject": "item.status" }, + "operator": "Equal", + "right": "Pending", + "enumeration": ["Pending", "Approved"] + }, + "arguments": { "orderId": { "subject": "item.id" } } + } + ], + "otherwise": { "outcome": "Hidden" }, + "navigateToScreen": "details", + "navigateByParameter": "id" +} +``` + +`item` is a normal Scene binding, resolved reactively by the renderer. When it is omitted, the current render data context is the subject. Scene never guesses a row from a query's first result. No selected item means no action, **even with an executable fallback**. + +Alternatives are checked in order; only the first match is selected. Omit `otherwise`, or use `{"outcome":"Hidden"}`, to hide the button when none matches. An executable fallback uses `{"outcome":"Execute","command":"ReviewOrder","arguments":{"orderId":{"subject":"item.id"}}}`. + +Arguments remain `{ "subject": "item.field" }` references, rather than binding expressions. Scene resolves them only at invocation. The dispatcher receives the selected `item` alongside the explicit arguments; an explicitly mapped argument named `item` takes precedence. Unresolved explicit arguments refuse execution. The button has `data-scene-command`, disables while its command is pending, and displays failures. A changed branch at click time causes a re-render without executing the stale choice. + +With an `InteractionScope`, Scene calls `dispatcher.executeCommand(command, args)` and navigates **only after a successful outcome**, using `dispatcher.navigate(screen, { [navigateByParameter]: item[navigateByParameter] })`. Without a navigation parameter, the arguments are empty. Failure does not select a different branch or run the fallback. + +Without an interaction scope, Scene dispatches `cratis.scene.command` on `window` with this detail: + +```typescript +{ + command, + element, + arguments: resolvedExplicitArguments, + item, + navigateToScreen, + navigateByParameter +} +``` + +That event is the host's execution seam. The host owns completion, failure reporting and navigation after success; Scene does not infer success from event delivery. The host must also prevent duplicate submissions while its asynchronous event handler runs. + +C# callers can use `GuardedActionProperties.Read(externalComponent)` and `ToProperties()` from `Cratis.Scene.Model.Guards` instead of hand-reading a JSON-backed dictionary. The matching TypeScript interfaces are exported by `@cratis/scene.model`. + +## Guard vocabulary + +A comparison contains `kind: "comparison"`, a `left` subject reference, an `operator` and a scalar `right` literal. Supported operators are `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Contains` and `StartsWith`. + +- Paths start with `item.` and may traverse nested object properties. Unsafe prototype paths and malformed paths are unsupported. +- Equality is type-strict. Missing fields never match. A **present null** matches a null literal with `Equal`; `NotEqual` involving null is false. +- Ordering requires numeric values; strings are not coerced to numbers. +- `Contains` supports a string substring or scalar array membership; `StartsWith` requires strings. String matching is case-sensitive. +- An optional `enumeration` lists member names in ordinal order. A subject ordinal or member name resolves to the member name. Enum names compare case-insensitively; unknown names and out-of-range ordinals do not match. +- A logical node is `{"kind":"logical","operator":"And","left":condition,"right":condition}`, or uses `Or`. Both branches must be understood, even if one branch would determine the answer. + +Unknown operators, node kinds and legacy string guards fail closed with `SCENE-GUARD-001`. A guarded action renders a disabled button and diagnostic rather than using its fallback. There is no JavaScript expression execution. + +`GuardConditions` and `GuardedSelection` in `Cratis.Scene.Engine.Guards` mirror `canEvaluateGuard`, `evaluateGuard`, `selectGuardedAction` and `selectGuardedInteraction` in `@cratis/scene.engine`. Their shared examples are in `guarded-selection-fixtures.json`. + +## Guarded interaction binding + +An interaction can select a whole action list instead of a single command: + +```json +{ + "trigger": { "kind": "Click" }, + "actions": [], + "alternatives": [ + { + "condition": { + "kind": "comparison", + "left": { "subject": "item.canApprove" }, + "operator": "Equal", + "right": true + }, + "actions": [{ "kind": "ExecuteCommand", "command": "ApproveOrder" }] + } + ], + "otherwise": [{ "kind": "OpenDialog", "dialogTemplate": "ReviewOrder" }] +} +``` + +Set `actions` to an empty list when supplying `alternatives`; combining a nonempty `actions` list with alternatives is refused. The gesture's subject comes from `InteractionContext.subject()` when supplied, then `resolve({path: 'item'})`. React supplies the render data context when no explicit subject callback exists. + +The engine reads the subject once, selects once, and runs the chosen list with the existing sequencing rules. A command failure never falls through to another branch. With no subject, nothing runs, including `otherwise`. With an evaluable no-match and no `otherwise`, nothing runs. An unknown predicate reports an `unevaluableCondition` finding, with `SCENE-GUARD-001`, and does not execute otherwise. + +The existing binding-expression `condition` remains unchanged, including host-owned `$guard` resolution. Existing `actions`-only bindings and the three-parameter C# `InteractionBinding` constructor remain available. + +## Stage adoption + +After releasing Scene, Stage can bump its Scene npm and NuGet references, emit `core:guarded-action` with an explicit selected-item binding, emit one interaction binding with ordered alternatives, and provide the gesture subject. Stage must still supply command routes and forms for every alternative and executable fallback. Legacy string guards must be converted to structured guards, not evaluated as code. These Stage changes are a separate change; this Scene feature does not modify Stage or its refusal policy. diff --git a/Documentation/toc.yml b/Documentation/toc.yml index ea5fdde5..38ece499 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -7,6 +7,8 @@ href: architecture/layering.md - name: Binding resolution href: architecture/binding-resolution.md + - name: Guarded actions and interactions + href: architecture/guarded-actions.md - name: Glossary href: glossary.md - name: Packages From eb5b040b6e5a546aa28f1c52a0f91e47f77079b9 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 19:16:12 +0100 Subject: [PATCH 6/8] Preserve null literals and subjects in typed guard evaluation --- .../when_running_the_shared_corpus.cs | 15 +++++++++++++++ Source/DotNET/Engine/Guards/GuardConditions.cs | 2 +- .../when_round_tripping_stage_properties.cs | 8 ++++++++ .../Model/Guards/ComparisonGuardCondition.cs | 8 +++++++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs index e2d041f9..76f84739 100644 --- a/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs +++ b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Cratis.Scene.Engine.Guards; +using Cratis.Scene.Model.Guards; namespace Cratis.Scene.Engine.for_GuardedSelection; @@ -17,6 +18,20 @@ void Establish() _corpus = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "guarded-selection-fixtures.json"))).RootElement; } + [Fact] + void should_preserve_present_nulls_when_evaluating_a_typed_guard() + { + var condition = new ComparisonGuardCondition(new("item.value"), GuardOperator.Equal, null); + GuardConditions.Evaluate(condition, new { value = (string?)null }).ShouldEqual(true); + } + + [Fact] + void should_evaluate_a_typed_enum_comparison() + { + var condition = new ComparisonGuardCondition(new("item.status"), GuardOperator.Equal, "approved", ["Pending", "Approved"]); + GuardConditions.Evaluate(condition, new { status = 1 }).ShouldEqual(true); + } + [Fact] void should_evaluate_every_shared_predicate() { diff --git a/Source/DotNET/Engine/Guards/GuardConditions.cs b/Source/DotNET/Engine/Guards/GuardConditions.cs index 0761289c..dff5d361 100644 --- a/Source/DotNET/Engine/Guards/GuardConditions.cs +++ b/Source/DotNET/Engine/Guards/GuardConditions.cs @@ -11,7 +11,7 @@ namespace Cratis.Scene.Engine.Guards; /// public static class GuardConditions { - static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; + static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); static readonly HashSet _forbidden = ["__proto__", "prototype", "constructor"]; /// diff --git a/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs b/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs index 05825058..7a076e8d 100644 --- a/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs +++ b/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs @@ -31,6 +31,14 @@ void Because() _interaction = JsonSerializer.SerializeToElement(binding, _options); } + [Fact] + void should_preserve_a_null_literal_when_writing_the_property_bag() + { + var properties = new GuardedActionProperties("Check", [new("Check", new ComparisonGuardCondition(new("item.value"), GuardOperator.Equal, null))]); + var json = JsonSerializer.SerializeToElement(properties.ToProperties(), _options); + json.GetProperty("alternatives")[0].GetProperty("condition").GetProperty("right").ValueKind.ShouldEqual(JsonValueKind.Null); + } + [Fact] void should_preserve_the_command() => _roundTrip.Alternatives[0].Command.ShouldEqual("Approve"); [Fact] void should_accept_stage_properties_without_a_subject_binding() => _roundTrip.Item.ShouldBeNull(); [Fact] void should_preserve_explicit_arguments() => _roundTrip.Alternatives[0].Arguments!["id"].Subject.ShouldEqual("item.id"); diff --git a/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs b/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs index f5df4364..5400244f 100644 --- a/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs +++ b/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs @@ -1,6 +1,8 @@ // 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.Json.Serialization; + namespace Cratis.Scene.Model.Guards; /// @@ -10,4 +12,8 @@ namespace Cratis.Scene.Model.Guards; /// The comparison. /// A string, number, boolean or null literal. /// The member names, in ordinal order. -public record ComparisonGuardCondition(SubjectReference Left, GuardOperator Operator, object? Right, IReadOnlyList? Enumeration = null) : GuardCondition; +public record ComparisonGuardCondition( + SubjectReference Left, + GuardOperator Operator, + [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] object? Right, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? Enumeration = null) : GuardCondition; From 1061de806406b38001e2edaa779cf2d5d50c04b5 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 19:45:56 +0100 Subject: [PATCH 7/8] Fix live interaction subjects and legacy binding deconstruction --- ...st_an_earlier_scene_constructs_a_record.cs | 27 +++++++++++++++++++ .../Model/Interactions/InteractionBinding.cs | 13 +++++++++ .../when_selecting_a_guarded_interaction.tsx | 18 ++++++++++++- .../react/interactions/useInteractions.ts | 6 +++-- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs b/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs index db494ad4..7443dba5 100644 --- a/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs +++ b/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs @@ -33,6 +33,33 @@ static readonly (Type Type, Type[] Parameters)[] _constructors = [Fact] void should_still_have_every_constructor() => _missing.ShouldBeEmpty(); + [Fact] + void should_keep_the_three_output_interaction_deconstruct_signature() + { + var method = typeof(InteractionBinding).GetMethod("Deconstruct", + [ + typeof(InteractionTrigger).MakeByRefType(), + typeof(IReadOnlyList).MakeByRefType(), + typeof(BindingExpression).MakeByRefType() + ]); + method.ShouldNotBeNull(); + method.ReturnType.ShouldEqual(typeof(void)); + method.GetParameters().All(parameter => parameter.IsOut).ShouldBeTrue(); + } + + [Fact] + void should_deconstruct_the_first_three_interaction_members_using_the_earlier_syntax() + { + var expectedTrigger = new BuiltInInteractionTrigger(InteractionTriggerKind.Click); + IReadOnlyList expectedActions = []; + var expectedCondition = new BindingExpression("allowed"); + var (trigger, actions, condition) = new InteractionBinding(expectedTrigger, expectedActions, expectedCondition, Alternatives: [], Otherwise: []); + + trigger.ShouldEqual(expectedTrigger); + actions.ShouldEqual(expectedActions); + condition.ShouldEqual(expectedCondition); + } + [Fact] void should_build_the_same_profile_through_the_earlier_constructor() { diff --git a/Source/DotNET/Model/Interactions/InteractionBinding.cs b/Source/DotNET/Model/Interactions/InteractionBinding.cs index 6099fe54..a0d35852 100644 --- a/Source/DotNET/Model/Interactions/InteractionBinding.cs +++ b/Source/DotNET/Model/Interactions/InteractionBinding.cs @@ -32,4 +32,17 @@ public InteractionBinding(InteractionTrigger trigger, IReadOnlyList + /// Preserves the three-output deconstruction used by earlier packages. + /// + /// What starts it. + /// The actions to run. + /// The existing binding guard. + public void Deconstruct(out InteractionTrigger trigger, out IReadOnlyList actions, out BindingExpression? condition) + { + trigger = Trigger; + actions = Actions; + condition = Condition; + } } diff --git a/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx b/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx index 22a09fea..1b588866 100644 --- a/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx +++ b/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx @@ -18,11 +18,27 @@ const behavior: Behavior = { }], }; +const behaviors = [behavior]; + function Button() { - return ; + return ; } describe('when selecting a guarded interaction', () => { + it('should replace the gesture subject while behaviors and interaction scope remain stable', async () => { + const dispatcher = new RecordingDispatcher(); + const attachments: [] = []; + const content = (dataContext: unknown) => +