From 5b185c93ff424b9542c4ca3ef359c7edf65efa25 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 8 Sep 2026 01:14:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(central-ui):=20IFlowDebugService=20script?= =?UTF-8?q?=20entry=20=E2=80=94=20GetInstanceScripts=20/=20PredictScript?= =?UTF-8?q?=20/=20RunScript=20over=20the=20flattened=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../Services/FlowDebugService.cs | 221 +++++++++++++----- .../Services/IFlowDebugService.cs | 54 +++-- .../Services/FlowDebugServiceTests.cs | 121 +++++++--- 3 files changed, 286 insertions(+), 110 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs index fa78b341..05aee3a1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs @@ -9,8 +9,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; @@ -20,7 +24,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// /// Default : prediction runs in-process over the repositories -/// (method script + instance → template chain → script bodies, DB-only, nothing executes); +/// (method script + instance → template chain → script bodies, DB-only, nothing executes), +/// while the script entry (design 2026-09-08 §2) resolves an instance's scripts through the +/// DeploymentManager's so the picker, the prediction and the +/// run all see the same canonical names, trigger kinds and effective parameter definitions the +/// runner will; /// the run/replay commands go to the per-node ManagementActor through /// , exactly as /// does, with the current Blazor principal projected to an @@ -58,8 +66,8 @@ public sealed class FlowDebugService : IFlowDebugService } /// - /// Client-side Ask wait for : the trigger-wait window - /// plus . + /// Client-side Ask wait for a WRITE-kind script run (): the + /// trigger-wait window plus . /// public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds) => TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack; @@ -67,6 +75,13 @@ public sealed class FlowDebugService : IFlowDebugService /// Upper bound on the template parent chain the script resolver walks (cycle guard). private const int MaxTemplateChain = 16; + /// + /// Ask-sizing fallback for a call-kind script that declares no timeout of its own — mirrors + /// SiteRuntimeOptions.ScriptExecutionTimeoutSeconds, the site default the run would + /// actually be bounded by (the UI has no site options to read it from). + /// + private const int DefaultScriptTimeoutSeconds = 30; + private static readonly JsonSerializerOptions ResultJsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -77,15 +92,32 @@ public sealed class FlowDebugService : IFlowDebugService private readonly AuthenticationStateProvider _auth; private readonly IInboundApiRepository _inboundApi; private readonly ITemplateEngineRepository _templates; + private readonly IFlatteningPipeline _pipeline; private readonly CommunicationService _comms; private readonly ILogger _logger; + /// + /// Flattened script lists by instance id. The service is scoped (one per Blazor circuit + /// interaction), so this costs at most one flatten per instance the operator selects, + /// and the picker, the prediction and the run all read the same snapshot. The raw + /// s ride along because prediction needs the code bodies, + /// which the picker's projection deliberately does not carry. + /// + private readonly Dictionary _scriptCache = new(); + + /// One instance's flattened scripts: the unique name the site keys on, the raw resolved scripts, and the picker projection. + private sealed record InstanceScripts( + string UniqueName, + IReadOnlyList Scripts, + IReadOnlyList Options); + /// Initializes the service. public FlowDebugService( ManagementActorHolder holder, AuthenticationStateProvider auth, IInboundApiRepository inboundApi, ITemplateEngineRepository templates, + IFlatteningPipeline pipeline, CommunicationService comms, ILogger logger) { @@ -93,6 +125,7 @@ public sealed class FlowDebugService : IFlowDebugService _auth = auth ?? throw new ArgumentNullException(nameof(auth)); _inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi)); _templates = templates ?? throw new ArgumentNullException(nameof(templates)); + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); _comms = comms ?? throw new ArgumentNullException(nameof(comms)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -161,43 +194,39 @@ public sealed class FlowDebugService : IFlowDebugService } /// - public async Task PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default) + public async Task> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default) + => (await LoadScriptsAsync(instanceId, cancellationToken))?.Options ?? []; + + /// + public async Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default) { - var instance = await _templates.GetInstanceByIdAsync(instanceId, cancellationToken); - if (instance == null) + var loaded = await LoadScriptsAsync(instanceId, cancellationToken); + var script = loaded?.Scripts.FirstOrDefault(s => s.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase)); + if (loaded == null || script == null) return PredictedFlow.Empty; - var scripts = await CollectEffectiveScriptsAsync(instance.TemplateId, cancellationToken); - var triggered = scripts.Values - .Where(s => IsValueChangeOn(s, attributeName)) - .OrderBy(s => s.Name, StringComparer.Ordinal) - .ToList(); + var title = $"{loaded.UniqueName}.{script.CanonicalName}"; + if (ScriptTriggerAttribute.TryRead(script.TriggerType, script.TriggerConfiguration, out var attribute) && attribute != null) + return PredictValueChange(loaded, attribute); - // Root: the write itself (Tags lane), then each triggered script predicted as a site script. - var nodes = new List + if (ScriptTriggerAttribute.IsWriteKind(script.TriggerType)) { - new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{instance.UniqueName}.{attributeName}", false, - triggered.Count == 0 ? "no value-change script on this attribute" : null), - }; - var edges = new List(); - foreach (var script in triggered) - { - var sub = FlowShapePredictor.PredictSiteScript($"{instance.UniqueName}.{script.Name}", script.Code ?? string.Empty); - var offset = nodes.Count; - var map = new Dictionary(StringComparer.Ordinal); - foreach (var n in sub.Nodes) - { - var id = "n" + (nodes.Count + 1); - map[n.Id] = id; - nodes.Add(n with { Id = id }); - } - - if (sub.Nodes.Count > 0) - edges.Add(new PredictedEdge("n1", nodes[offset].Id, false)); - foreach (var e in sub.Edges) - edges.Add(e with { From = map[e.From], To = map[e.To] }); + // A write-kind trigger whose configuration names no attribute: nothing to write, so + // the run cannot be predicted past the script itself. + return new PredictedFlow( + [new PredictedNode("n1", FlowStepKind.ScriptRun, FlowLane.Site, title, false, "trigger names no attribute")], + []); } + // Call kinds: the debugger invokes the script directly through the traced routed-call + // path, so the root is a Central-lane RouteCall and the site script hangs off it across + // the cluster boundary — the same shape a `Route.To(instance).Call(script)` renders. + var nodes = new List + { + new("n1", FlowStepKind.RouteCall, FlowLane.Central, title, false, null), + }; + var edges = new List(); + AppendSubFlow(FlowShapePredictor.PredictSiteScript(title, script.Code ?? string.Empty), nodes, edges, crossesBoundary: true); return new PredictedFlow(nodes, edges); } @@ -210,10 +239,22 @@ public sealed class FlowDebugService : IFlowDebugService } /// - public Task RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default) + public async Task RunScriptAsync( + int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default) { - var command = new FlowDebugRunValueChangeCommand(instanceId, attributeName, value); - return SendAndBuildAsync(command, ValueChangeAskTimeoutFor(command.WaitSeconds), cancellationToken); + // The server re-resolves the script and never trusts a client-supplied trigger kind + // (design 2026-09-08 §2); the kind is read here only to size the Ask wait — a call kind + // is bounded by the script's own execution timeout, a write kind by the trigger-wait + // window. An unresolvable script falls back to the write-kind wait, which is the longer + // of the two for a default-timeout script. + var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value); + var option = (await GetInstanceScriptsAsync(instanceId, cancellationToken)) + .FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase)); + var timeout = option is { IsWriteKind: false } + ? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds) + : ValueChangeAskTimeoutFor(command.WaitSeconds); + + return await SendAndBuildAsync(command, timeout, cancellationToken); } /// @@ -366,38 +407,94 @@ public sealed class FlowDebugService : IFlowDebugService return result; } - /// True when the script's trigger is a value-change on (case-insensitive). - private static bool IsValueChangeOn(TemplateScript script, string attributeName) + /// + /// Loads an instance's scripts from its flattened configuration (design 2026-09-08 §2) — + /// canonical names, effective trigger kinds and effective parameter definitions, i.e. exactly + /// what the site's Instance Actor keys on — memoised for the life of this scoped service. + /// Returns null when the instance does not exist or the flatten failed; a flatten failure is + /// a debug-level note, never a page error, because the picker simply has nothing to offer. + /// + private async Task LoadScriptsAsync(int instanceId, CancellationToken ct) { - if (!string.Equals(script.TriggerType, "valuechange", StringComparison.OrdinalIgnoreCase) - && !string.Equals(script.TriggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)) - return false; + if (_scriptCache.TryGetValue(instanceId, out var cached)) + return cached; - var cfg = script.TriggerConfiguration; - if (string.IsNullOrWhiteSpace(cfg)) - return false; + var instance = await _templates.GetInstanceByIdAsync(instanceId, ct); + if (instance == null) + return null; - try + var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false); + if (flattened.IsFailure) { - using var doc = JsonDocument.Parse(cfg); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return false; - foreach (var prop in doc.RootElement.EnumerateObject()) - { - if (prop.NameEquals("attribute") || prop.NameEquals("Attribute") - || prop.NameEquals("attributeName") || prop.NameEquals("AttributeName")) - { - return prop.Value.ValueKind == JsonValueKind.String - && string.Equals(prop.Value.GetString(), attributeName, StringComparison.OrdinalIgnoreCase); - } - } - } - catch (JsonException) - { - // Malformed trigger config — not something prediction should fail over. + _logger.LogDebug("Flow Debugger could not flatten instance {InstanceId}: {Error}", instanceId, flattened.Error); + return null; } - return false; + var scripts = flattened.Value.Configuration.Scripts + .OrderBy(s => s.CanonicalName, StringComparer.OrdinalIgnoreCase) + .ToList(); + var options = scripts + .Select(s => new FlowScriptOption( + s.CanonicalName, + s.TriggerType, + ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) ? a : null, + s.ParameterDefinitions, + s.ExecutionTimeoutSeconds)) + .ToList(); + + var loaded = new InstanceScripts(instance.UniqueName, scripts, options); + _scriptCache[instanceId] = loaded; + return loaded; + } + + /// + /// Predicts the flow a write to would trigger: the write + /// itself on the Tags lane, then every script whose write-kind trigger names that attribute + /// predicted as a site script. Conditional triggers are included alongside ValueChange — + /// both name a single monitored attribute and both really fire on the write. + /// + private static PredictedFlow PredictValueChange(InstanceScripts loaded, string attributeName) + { + var triggered = loaded.Scripts + .Where(s => ScriptTriggerAttribute.TryRead(s.TriggerType, s.TriggerConfiguration, out var a) + && string.Equals(a, attributeName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var nodes = new List + { + new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{loaded.UniqueName}.{attributeName}", false, + triggered.Count == 0 ? "no value-change script on this attribute" : null), + }; + var edges = new List(); + foreach (var script in triggered) + { + var sub = FlowShapePredictor.PredictSiteScript($"{loaded.UniqueName}.{script.CanonicalName}", script.Code ?? string.Empty); + AppendSubFlow(sub, nodes, edges, crossesBoundary: false); + } + + return new PredictedFlow(nodes, edges); + } + + /// + /// Appends a predicted sub-flow to /, + /// renumbering its node ids into the outer sequence and linking its first node to the root + /// (n1). + /// + private static void AppendSubFlow(PredictedFlow sub, List nodes, List edges, bool crossesBoundary) + { + var offset = nodes.Count; + var map = new Dictionary(StringComparer.Ordinal); + foreach (var n in sub.Nodes) + { + var id = "n" + (nodes.Count + 1); + map[n.Id] = id; + nodes.Add(n with { Id = id }); + } + + if (sub.Nodes.Count > 0) + edges.Add(new PredictedEdge("n1", nodes[offset].Id, crossesBoundary)); + foreach (var e in sub.Edges) + edges.Add(e with { From = map[e.From], To = map[e.To] }); } private async Task SendAndBuildAsync(object command, TimeSpan timeout, CancellationToken ct) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs index b055d0b2..ea2db37b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; @@ -34,9 +35,32 @@ public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw, public sealed record FlowOutboundActionResult(bool Success, string Message); /// -/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1): static shape -/// prediction from script syntax, and the three management commands (Live run of a method, -/// Live run of a value change, Replay) dispatched to the per-node ManagementActor +/// One script of an instance as the Flow Debugger's script picker shows it (design 2026-09-08 §5): +/// canonical name, trigger kind, the trigger's attribute for write kinds, the effective parameter +/// definitions for call kinds, and the script's own execution timeout (client Ask sizing). +/// +/// Path-qualified script name from the flattened configuration (Module.Script for a composed member). +/// The effective trigger type, or null when the script has none. +/// The monitored attribute of a write-kind trigger; null for every other kind. +/// JSON parameter definitions the Inputs card renders for a call kind; null = none. +/// The script's own execution timeout, or null to use the site default. +public sealed record FlowScriptOption( + string CanonicalName, string? TriggerType, string? TriggerAttribute, + string? ParameterDefinitions, int? ExecutionTimeoutSeconds) +{ + /// True when the debugger fires this script by writing . + public bool IsWriteKind => ScriptTriggerAttribute.IsWriteKind(TriggerType); + + /// Picker label suffix: ValueChange: Level, Call, no trigger… + public string TriggerLabel => string.IsNullOrWhiteSpace(TriggerType) ? "no trigger" + : TriggerAttribute is { } a ? $"{TriggerType}: {a}" : TriggerType!; +} + +/// +/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1, script entry +/// 2026-09-08 §5): the instance's script list read from its flattened configuration, static +/// shape prediction from script syntax, and the three management commands (Live run of a +/// method, Live run of a script, Replay) dispatched to the per-node ManagementActor /// through the in-process ManagementActorHolder — the SAME Ask path the HTTP /// /management endpoint uses, so the server stays the single enforcer of the Deployer /// gate and the active-node refusal (STANDBY_NODE). @@ -49,15 +73,16 @@ public interface IFlowDebugService /// The predicted flow; when the method does not exist. Task PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default); - /// - /// Predicts the flow a value change on would trigger: the - /// instance's template scripts whose value-change trigger names that attribute, each predicted - /// as a site script. - /// + /// The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist. /// The instance id. - /// Canonical attribute name. /// Cancellation token. - Task PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default); + Task> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default); + + /// Predicts the flow of running : the write + spawned script for a write kind, a RouteCall root + the script for a call kind. + /// The instance id. + /// Canonical script name (case-insensitive). + /// Cancellation token. + Task PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default); /// Runs an inbound API method with tracing on the active central node and returns the flow. /// The method id. @@ -65,12 +90,13 @@ public interface IFlowDebugService /// Cancellation token. Task RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default); - /// Writes an attribute through the traced path and returns the flow the change triggered. + /// Runs a script by name (write kinds take , call kinds ) and returns the flow. /// The instance id. - /// Canonical attribute name. - /// Value in canonical string form. + /// Canonical script name (case-insensitive). + /// Call kinds: flat JSON object of parameters, or null. + /// Write kinds: the new value for the trigger's attribute, in canonical string form. /// Cancellation token. - Task RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default); + Task RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default); /// Rebuilds a past flow from the central audit store. /// Any execution id in the chain. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs index 43b78400..535da2ac 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs @@ -17,7 +17,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; using ZB.MOM.WW.ScadaBridge.Security; @@ -27,8 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// /// Tests for (Flow Debugger design 2026-09-05 §5.5/§7.1): /// the management-command bridge (holder null, error mapping, success → graph), the -/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay -/// for a parked outbound node (follow-ups plan 2026-09-06 D4). +/// prediction resolver wiring over substituted repositories, the script entry (design +/// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and +/// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4). /// /// /// is a concrete class with non-virtual methods, so the @@ -48,6 +52,7 @@ public sealed class FlowDebugServiceTests : IDisposable private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests"); private readonly IInboundApiRepository _inboundApi = Substitute.For(); private readonly ITemplateEngineRepository _templates = Substitute.For(); + private readonly IFlatteningPipeline _pipeline = Substitute.For(); private readonly List _received = new(); // Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive. @@ -80,7 +85,21 @@ public sealed class FlowDebugServiceTests : IDisposable comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)))); comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)))); - return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger.Instance); + return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger.Instance); + } + + /// + /// Wires the instance row plus the flattened configuration its scripts are read from + /// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through + /// , never the raw template chain). + /// + private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts) + { + _templates.GetInstanceByIdAsync(instanceId, Arg.Any()) + .Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 }); + var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts }; + _pipeline.FlattenAndValidateAsync(instanceId, Arg.Any(), false, null) + .Returns(Result.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult()))); } /// An External-lane step as FlowStepProjector would project it (positional per ). @@ -337,19 +356,21 @@ public sealed class FlowDebugServiceTests : IDisposable } [Fact] - public async Task RunValueChange_AndReplay_SendTheirCommands() + public async Task RunWriteKindScript_AndReplay_SendTheirCommands() { + WireFlattened(7, "Sim01", + new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" }); var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1))); var exec = Guid.NewGuid(); - var vc = await svc.RunValueChangeAsync(7, "Speed", "42"); + var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "42"); var rp = await svc.ReplayAsync(exec); Assert.True(vc.Success); Assert.True(rp.Success); Assert.Equal(2, _received.Count); - var vcCmd = Assert.IsType(_received[0].Command); - Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value)); + var vcCmd = Assert.IsType(_received[0].Command); + Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value)); var rpCmd = Assert.IsType(_received[1].Command); Assert.Equal(exec, rpCmd.ExecutionId); } @@ -519,22 +540,36 @@ public sealed class FlowDebugServiceTests : IDisposable } [Fact] - public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript() + public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName() { - _templates.GetInstanceByIdAsync(9, Arg.Any()) - .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); - _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) - .Returns(Task.FromResult>( - [ - new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" }, - new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" }, - new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" }, - ])); - _templates.GetTemplateByIdAsync(10, Arg.Any()) - .Returns(new Template("Base") { Id = 10 }); + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 }, + new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }, + new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" }); - var svc = CreateService(reply: null); - var flow = await svc.PredictValueChangeAsync(9, "Flag"); + var options = await CreateService(reply: null).GetInstanceScriptsAsync(9); + + Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList()); + var onFlag = options.Single(o => o.CanonicalName == "OnFlag"); + Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel); + var manual = options.Single(o => o.CanonicalName == "Manual"); + Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel); + Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind); + } + + [Fact] + public async Task GetInstanceScripts_UnknownInstance_IsEmpty() + { + Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404)); + } + + [Fact] + public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript() + { + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }); + + var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag"); Assert.Equal( [ @@ -542,25 +577,43 @@ public sealed class FlowDebugServiceTests : IDisposable (FlowStepKind.ScriptRun, "Sim01.OnFlag"), (FlowStepKind.AttributeWrite, "Ack"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); - Assert.Equal(2, flow.Edges.Count); - Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To)); - Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To)); } [Fact] - public async Task PredictValueChange_NoTriggeredScript_NotesIt() + public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript() { - _templates.GetInstanceByIdAsync(9, Arg.Any()) - .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); - _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) - .Returns(Task.FromResult>([])); - _templates.GetTemplateByIdAsync(10, Arg.Any()).Returns(new Template("Base") { Id = 10 }); + WireFlattened(9, "Sim01", + new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" }); - var svc = CreateService(reply: null); - var flow = await svc.PredictValueChangeAsync(9, "Flag"); + var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual"); - var root = Assert.Single(flow.Nodes); - Assert.Contains("no value-change script", root.Note); + Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind); + Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane); + Assert.Equal("Sim01.Manual", flow.Nodes[0].Title); + Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind); + Assert.True(flow.Edges[0].CrossesBoundary); + } + + [Fact] + public async Task PredictScript_UnknownScript_IsEmpty() + { + WireFlattened(9, "Sim01"); + Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope")); + } + + [Fact] + public async Task RunScript_SendsTheCommand_WithTheScriptShape() + { + WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); + var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); + + var outcome = await svc.RunScriptAsync(9, "Manual", null, null); + + Assert.True(outcome.Success, outcome.Error); + var env = Assert.Single(_received); + var cmd = Assert.IsType(env.Command); + Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); } private sealed class StubAuth : AuthenticationStateProvider