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
@@ -24,8 +24,8 @@
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point"> <div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")" <button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button> data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")" <button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button> data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
</div> </div>
</div> </div>
} }
@@ -70,14 +70,14 @@
} }
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-3">
<label class="form-label form-label-sm">Attribute</label> <label class="form-label form-label-sm">Script</label>
<select class="form-select form-select-sm" data-test="flow-attribute-select" <select class="form-select form-select-sm" data-test="flow-script-select"
@bind="_selectedAttribute" @bind:after="OnAttributeChangedAsync" disabled="@(_running || _selectedInstanceId == 0)"> @bind="_selectedScript" @bind:after="OnScriptChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
<option value="">— choose —</option> <option value="">— choose —</option>
@foreach (var a in _attributes) @foreach (var s in _scripts)
{ {
<option value="@a">@a</option> <option value="@s.CanonicalName">@s.CanonicalName (@s.TriggerLabel)</option>
} }
</select> </select>
</div> </div>
@@ -193,7 +193,7 @@
<div class="row g-3"> <div class="row g-3">
@* ── Inputs (Live only) ────────────────────────────────────────────── *@ @* ── Inputs (Live only) ────────────────────────────────────────────── *@
@if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0)) @if (_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0))
{ {
<div class="col-lg-3"> <div class="col-lg-3">
<div class="card fd-inputs" data-test="flow-inputs"> <div class="card fd-inputs" data-test="flow-inputs">
@@ -214,13 +214,33 @@
</div> </div>
} }
} }
else else if (SelectedScript is { } script)
{ {
<div class="mb-2"> @* A write kind is fired by writing its own trigger attribute — the operator
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(_selectedAttribute == "" ? "…" : _selectedAttribute)</span></label> supplies only the value. Every other kind is called directly with the
<input class="form-control form-control-sm" data-test="flow-input-value" parameters its definitions declare. *@
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" /> @if (script.IsWriteKind)
</div> {
<div class="mb-2">
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(script.TriggerAttribute ?? "…")</span></label>
<input class="form-control form-control-sm" data-test="flow-input-value"
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
<div class="form-text">@script.TriggerType trigger — writing the attribute fires the script.</div>
</div>
}
else if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
<div class="text-muted small" data-test="flow-script-no-params">This script takes no parameters.</div>
}
else
{
<div data-test="flow-script-params">
<ParameterValueForm ParameterDefinitions="@script.ParameterDefinitions"
Values="_scriptParams"
ValuesChanged="@(v => _scriptParams = v)"
ValidChanged="@(ok => _scriptParamsValid = ok)" />
</div>
}
} }
</div> </div>
</div> </div>
@@ -228,7 +248,7 @@
} }
@* ── Flow panel ────────────────────────────────────────────────────── *@ @* ── Flow panel ────────────────────────────────────────────────────── *@
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0) ? "col-lg-9" : "col-12")"> <div class="@(_mode == FlowMode.Live && (_entry == EntryKind.Script ? SelectedScript != null : _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
<div class="fd-flow-tools"> <div class="fd-flow-tools">
<div class="small text-muted"> <div class="small text-muted">
@if (_graph == null && _predicted != null) @if (_graph == null && _predicted != null)
@@ -254,8 +274,8 @@
@* Retry / Discard relay a command to the site — only wired for a Deployer, so the @* Retry / Discard relay a command to the site — only wired for a Deployer, so the
buttons never render for an auditor (arch-review F3). *@ buttons never render for an auditor (arch-review F3). *@
<FlowStepDetail Step="_selected" Graph="_graph" <FlowStepDetail Step="_selected" Graph="_graph"
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)" SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)" InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
OnRetry="RetryCallback" OnDiscard="DiscardCallback" OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" /> ActionInProgress="_actionInProgress" />
</div> </div>
@@ -21,9 +21,9 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary> /// <summary>
/// Flow Debugger page (design 2026-09-05 §4): pick an entry point (inbound API method or a /// Flow Debugger page (design 2026-09-05 §4, script entry 2026-09-08 §5): pick an entry point
/// value-change trigger), see the predicted swimlane, run it live with tracing on the active /// (inbound API method or a site script), see the predicted swimlane, run it live with tracing on
/// central node, or replay a past execution from the audit store. State machine /// the active central node, or replay a past execution from the audit store. State machine
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's /// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim. /// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
/// </summary> /// </summary>
@@ -34,8 +34,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{ {
/// <summary>An inbound API method.</summary> /// <summary>An inbound API method.</summary>
Method, Method,
/// <summary>A value-change trigger on an instance attribute.</summary> /// <summary>
ValueChange, /// A script on a site instance: a write-kind trigger (ValueChange / Conditional) is fired
/// by writing its monitored attribute, every other kind is called directly.
/// </summary>
Script,
} }
/// <summary>Live vs Replay.</summary> /// <summary>Live vs Replay.</summary>
@@ -68,7 +71,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private const string StorageMethod = "flowDebugger.methodId"; private const string StorageMethod = "flowDebugger.methodId";
private const string StorageSite = "flowDebugger.siteId"; private const string StorageSite = "flowDebugger.siteId";
private const string StorageInstance = "flowDebugger.instanceId"; private const string StorageInstance = "flowDebugger.instanceId";
private const string StorageAttribute = "flowDebugger.attribute"; private const string StorageScript = "flowDebugger.script";
private const string StorageInputsPrefix = "flowDebugger.inputs."; private const string StorageInputsPrefix = "flowDebugger.inputs.";
[Inject] private IFlowDebugService FlowDebug { get; set; } = default!; [Inject] private IFlowDebugService FlowDebug { get; set; } = default!;
@@ -83,11 +86,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary>Drill-in: pre-select an inbound method.</summary> /// <summary>Drill-in: pre-select an inbound method.</summary>
[SupplyParameterFromQuery] public int? MethodId { get; set; } [SupplyParameterFromQuery] public int? MethodId { get; set; }
/// <summary>Drill-in: pre-select an instance (value-change entry).</summary> /// <summary>Drill-in: pre-select an instance (script entry).</summary>
[SupplyParameterFromQuery] public int? InstanceId { get; set; } [SupplyParameterFromQuery] public int? InstanceId { get; set; }
/// <summary>Drill-in: pre-select the trigger attribute (with <see cref="InstanceId"/>).</summary> /// <summary>Drill-in: pre-select the script (with <see cref="InstanceId"/>).</summary>
[SupplyParameterFromQuery] public string? Attribute { get; set; } [SupplyParameterFromQuery] public string? Script { get; set; }
/// <summary>Drill-in: replay this execution on first render.</summary> /// <summary>Drill-in: replay this execution on first render.</summary>
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; } [SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
@@ -109,15 +112,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private List<ApiMethod> _methods = []; private List<ApiMethod> _methods = [];
private List<Site> _sites = []; private List<Site> _sites = [];
private List<Instance> _instances = []; private List<Instance> _instances = [];
private List<string> _attributes = []; private List<FlowScriptOption> _scripts = [];
private int _selectedMethodId; private int _selectedMethodId;
private int _selectedSiteId; private int _selectedSiteId;
private int _selectedInstanceId; private int _selectedInstanceId;
private string _selectedAttribute = string.Empty; private string _selectedScript = string.Empty;
private string _valueChangeValue = string.Empty; private string _valueChangeValue = string.Empty;
private string _replayExecutionIdText = string.Empty; private string _replayExecutionIdText = string.Empty;
/// <summary>The chosen script's option row, or null when nothing (or something stale) is selected.</summary>
private FlowScriptOption? SelectedScript
=> _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, _selectedScript, StringComparison.Ordinal));
/// <summary>Call-kind parameter values from <c>ParameterValueForm</c>, and whether it currently parses.</summary>
private Dictionary<string, object?> _scriptParams = new();
private bool _scriptParamsValid;
private List<InboundApiSchemaField> _inputFields = []; private List<InboundApiSchemaField> _inputFields = [];
private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal);
@@ -159,9 +170,13 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool CanRefresh => !_running && _raw is not null; private bool CanRefresh => !_running && _raw is not null;
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && ( private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
_entry == EntryKind.Method ? _selectedMethodId > 0 _entry == EntryKind.Method ? _selectedMethodId > 0
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute)); : SelectedScript is { } s && (s.IsWriteKind
? !string.IsNullOrWhiteSpace(_valueChangeValue)
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
private string StateLabel => _state switch private string StateLabel => _state switch
{ {
@@ -222,17 +237,19 @@ public partial class FlowDebugger : ComponentBase, IDisposable
if (InstanceId is int iid && iid > 0) if (InstanceId is int iid && iid > 0)
{ {
_entry = EntryKind.ValueChange; _entry = EntryKind.Script;
var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid); var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid);
if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId)) if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId))
{ {
_selectedSiteId = inst.SiteId; _selectedSiteId = inst.SiteId;
await LoadInstancesAsync(); await LoadInstancesAsync();
_selectedInstanceId = inst.Id; _selectedInstanceId = inst.Id;
await LoadAttributesAsync(); await LoadScriptsAsync();
if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute)) // The link may spell the script in any case; the picker binds the canonical one.
var named = _scripts.FirstOrDefault(s => string.Equals(s.CanonicalName, Script, StringComparison.OrdinalIgnoreCase));
if (named != null)
{ {
_selectedAttribute = Attribute; _selectedScript = named.CanonicalName;
await PredictAsync(); await PredictAsync();
} }
} }
@@ -266,12 +283,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
try try
{ {
var entry = await JS.InvokeAsync<string?>("localStorage.getItem", StorageEntry); var entry = await JS.InvokeAsync<string?>("localStorage.getItem", StorageEntry);
if (string.Equals(entry, nameof(EntryKind.ValueChange), StringComparison.Ordinal)) if (string.Equals(entry, nameof(EntryKind.Script), StringComparison.Ordinal))
{ {
_entry = EntryKind.ValueChange; _entry = EntryKind.Script;
var site = await JS.InvokeAsync<string?>("localStorage.getItem", StorageSite); var site = await JS.InvokeAsync<string?>("localStorage.getItem", StorageSite);
var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance); var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance);
var attr = await JS.InvokeAsync<string?>("localStorage.getItem", StorageAttribute); var script = await JS.InvokeAsync<string?>("localStorage.getItem", StorageScript);
if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid)) if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid))
{ {
_selectedSiteId = sid; _selectedSiteId = sid;
@@ -279,10 +296,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid)) if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid))
{ {
_selectedInstanceId = iid; _selectedInstanceId = iid;
await LoadAttributesAsync(); await LoadScriptsAsync();
if (!string.IsNullOrEmpty(attr) && _attributes.Contains(attr)) if (_scripts.Any(s => string.Equals(s.CanonicalName, script, StringComparison.Ordinal)))
{ {
_selectedAttribute = attr; _selectedScript = script!;
await PredictAsync(); await PredictAsync();
} }
} }
@@ -316,7 +333,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
await PersistAsync(StorageEntry, entry.ToString()); await PersistAsync(StorageEntry, entry.ToString());
if (_entry == EntryKind.Method && _selectedMethodId > 0) if (_entry == EntryKind.Method && _selectedMethodId > 0)
await OnMethodChangedAsync(); await OnMethodChangedAsync();
else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty) else if (_entry == EntryKind.Script && SelectedScript != null)
await PredictAsync(); await PredictAsync();
} }
@@ -355,8 +372,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
_predicted = null; _predicted = null;
_state = FlowState.Idle; _state = FlowState.Idle;
_selectedInstanceId = 0; _selectedInstanceId = 0;
_selectedAttribute = string.Empty; _selectedScript = string.Empty;
_attributes = []; _scripts = [];
await LoadInstancesAsync(); await LoadInstancesAsync();
await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture)); await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture));
} }
@@ -366,17 +383,25 @@ public partial class FlowDebugger : ComponentBase, IDisposable
ResetResult(); ResetResult();
_predicted = null; _predicted = null;
_state = FlowState.Idle; _state = FlowState.Idle;
_selectedAttribute = string.Empty; _selectedScript = string.Empty;
await LoadAttributesAsync(); await LoadScriptsAsync();
await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture)); await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture));
} }
private async Task OnAttributeChangedAsync() private async Task OnScriptChangedAsync()
{ {
ResetResult(); ResetResult();
await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString()); _predicted = null;
await PersistAsync(StorageAttribute, _selectedAttribute); _state = FlowState.Idle;
await PredictAsync(); // Inputs belong to the chosen script: a write kind's value and a call kind's parameter
// tree are both dropped so nothing carries across a change of script.
_scriptParams = new();
_scriptParamsValid = false;
_valueChangeValue = string.Empty;
await PersistAsync(StorageEntry, nameof(EntryKind.Script));
await PersistAsync(StorageScript, _selectedScript);
if (SelectedScript != null)
await PredictAsync();
} }
private async Task LoadInstancesAsync() private async Task LoadInstancesAsync()
@@ -386,14 +411,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
: []; : [];
} }
private async Task LoadAttributesAsync() /// <summary>The instance's scripts, from its flattened configuration (canonical names, effective triggers).</summary>
private async Task LoadScriptsAsync()
{ {
_attributes = []; _scripts = _selectedInstanceId > 0
var inst = _instances.FirstOrDefault(i => i.Id == _selectedInstanceId); ? (await FlowDebug.GetInstanceScriptsAsync(_selectedInstanceId)).ToList()
if (inst == null) : [];
return;
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
_attributes = attrs.Select(a => a.Name).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
} }
private async Task PredictAsync() private async Task PredictAsync()
@@ -402,7 +425,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{ {
_predicted = _entry == EntryKind.Method _predicted = _entry == EntryKind.Method
? await FlowDebug.PredictMethodAsync(_selectedMethodId) ? await FlowDebug.PredictMethodAsync(_selectedMethodId)
: await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute); : await FlowDebug.PredictScriptAsync(_selectedInstanceId, _selectedScript);
_state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle; _state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle;
} }
catch (Exception ex) catch (Exception ex)
@@ -445,7 +468,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
} }
else else
{ {
outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue); // A write kind is fired by writing its trigger attribute; every other kind is a
// direct traced call carrying the parameter form's values.
var script = SelectedScript!;
outcome = script.IsWriteKind
? await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, null, _valueChangeValue)
: await FlowDebug.RunScriptAsync(_selectedInstanceId, script.CanonicalName, BuildScriptParameters(), null);
} }
ApplyOutcome(outcome); ApplyOutcome(outcome);
@@ -801,6 +829,14 @@ public partial class FlowDebugger : ComponentBase, IDisposable
return JsonSerializer.SerializeToElement(obj); return JsonSerializer.SerializeToElement(obj);
} }
/// <summary>
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
/// </summary>
internal JsonElement? BuildScriptParameters()
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
private static JsonNode? Coerce(string? type, string raw) private static JsonNode? Coerce(string? type, string raw)
{ {
switch (InboundApiSchema.NormalizeType(type)) switch (InboundApiSchema.NormalizeType(type))
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; 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.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; 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 ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>(); private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>(); private readonly IDialogService _dialog = Substitute.For<IDialogService>();
private readonly ISchemaLibraryQueryService _schemaLibrary = Substitute.For<ISchemaLibraryQueryService>();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;") 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<bool>(), Arg.Any<string?>()).Returns(true);
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).Returns(true); _dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).Returns(true);
Services.AddSingleton(_dialog); 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( var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie"); [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-run']"));
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']")); 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-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']")); 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>())); cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()); 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>());
}
} }