feat(central-ui): Flow Debugger script entry — site/instance/script picker, value field or ParameterValueForm by trigger kind

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 01:21:30 -04:00
parent acf4ab3d4d
commit 6fe67e147c
3 changed files with 217 additions and 59 deletions
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -35,6 +36,7 @@ public class FlowDebuggerPageTests : BunitContext
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For<ISchemaLibraryQueryService>();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
{
@@ -91,6 +93,10 @@ public class FlowDebuggerPageTests : BunitContext
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).Returns(true);
Services.AddSingleton(_dialog);
// The script entry's Inputs card renders the shared ParameterValueForm, which injects
// the schema library to resolve {"$ref":"lib:Name"} nodes. Empty map = no refs.
_schemaLibrary.GetSchemaMapAsync(Arg.Any<CancellationToken>()).Returns(new Dictionary<string, string>());
Services.AddSingleton(_schemaLibrary);
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie");
@@ -145,7 +151,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']"));
Assert.Empty(cut.FindAll("button[data-test='flow-entry-script']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
}
@@ -589,4 +595,100 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
// ---- Script entry (design 2026-09-08 §5): site → instance → script ----------------------
private static readonly FlowScriptOption OnFlag = new("OnFlag", "ValueChange", "Flag", null, null);
private static readonly FlowScriptOption Manual = new("Manual", "Call", null,
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null);
private void WireInstance()
{
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_templates.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 });
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, OnFlag, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7");
var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList();
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
}
[Fact]
public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7");
cut.Find("select[data-test='flow-script-select']").Change("OnFlag");
Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.Empty(cut.FindAll("[data-test='flow-script-params']"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("input[data-test='flow-input-value']").Input("true");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
{
RegisterServices();
WireInstance();
_flow.RunScriptAsync(7, "Manual", Arg.Any<JsonElement?>(), null, Arg.Any<CancellationToken>()).Returns(GraphOutcome(true, true));
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7");
cut.Find("select[data-test='flow-script-select']").Change("Manual");
Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
Assert.Empty(cut.FindAll("input[data-test='flow-input-value']"));
cut.Find("#param-n").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
_ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
}
[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
RegisterServices();
WireInstance();
var cut = Render<FlowDebuggerPage>();
cut.Find("button[data-test='flow-entry-script']").Click();
cut.Find("select[data-test='flow-site-select']").Change("1");
cut.Find("select[data-test='flow-instance-select']").Change("7");
cut.Find("select[data-test='flow-script-select']").Change("Tick");
Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent);
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
{
RegisterServices();
WireInstance();
var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?instanceId=7&script=Manual");
var cut = Render<FlowDebuggerPage>();
cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
_ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any<CancellationToken>());
}
}