feat(central-ui): IFlowDebugService script entry — GetInstanceScripts / PredictScript / RunScript over the flattened configuration

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 01:14:40 -04:00
parent 3d5759613f
commit 5b185c93ff
3 changed files with 286 additions and 110 deletions
@@ -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;
/// <summary>
/// Tests for <see cref="FlowDebugService"/> (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).
/// </summary>
/// <remarks>
/// <see cref="CommunicationService"/> 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<IInboundApiRepository>();
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly List<ManagementEnvelope> _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<FlowDebugService>.Instance);
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
}
/// <summary>
/// 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
/// <see cref="IFlatteningPipeline"/>, never the raw template chain).
/// </summary>
private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
{
_templates.GetInstanceByIdAsync(instanceId, Arg.Any<CancellationToken>())
.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<CancellationToken>(), false, null)
.Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
}
/// <summary>An External-lane step as <c>FlowStepProjector</c> would project it (positional per <see cref="FlowStep"/>).</summary>
@@ -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<FlowDebugRunValueChangeCommand>(_received[0].Command);
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command);
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_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<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
[
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<CancellationToken>())
.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<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).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<FlowDebugRunScriptCommand>(env.Command);
Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName));
}
private sealed class StubAuth : AuthenticationStateProvider