fix(central-ui): Flow Debugger — remount ParameterValueForm per script so stale form state cannot block Run

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 01:32:29 -04:00
parent d9d8fdde05
commit a2bfc79e3a
3 changed files with 71 additions and 1 deletions
@@ -235,7 +235,12 @@
else
{
<div data-test="flow-script-params">
<ParameterValueForm ParameterDefinitions="@script.ParameterDefinitions"
@* Keyed on the script so a change of script REMOUNTS the form: its raw
text and parse errors are per-instance state that must not survive
(and two scripts may even declare byte-identical definitions, which
would otherwise skip the reparse and its ValidChanged entirely). *@
<ParameterValueForm @key="@script.CanonicalName"
ParameterDefinitions="@script.ParameterDefinitions"
Values="_scriptParams"
ValuesChanged="@(v => _scriptParams = v)"
ValidChanged="@(ok => _scriptParamsValid = ok)" />
@@ -330,6 +330,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
ResetResult();
_predicted = null;
_state = FlowState.Idle;
// Inputs are per-entry-point, as in OnScriptChangedAsync: never carry a script's value or
// parameter tree (or its validity) across a switch of entry kind.
_scriptParams = new();
_scriptParamsValid = false;
_valueChangeValue = string.Empty;
await PersistAsync(StorageEntry, entry.ToString());
if (_entry == EntryKind.Method && _selectedMethodId > 0)
await OnMethodChangedAsync();
@@ -691,4 +691,64 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Equal("Manual", cut.Find("select[data-test='flow-script-select']").GetAttribute("value")));
_ = _flow.Received().PredictScriptAsync(7, "Manual", Arg.Any<CancellationToken>());
}
/// <summary>
/// Two call-kind scripts declaring BYTE-IDENTICAL parameter definitions — the case that makes
/// <c>ParameterValueForm</c> skip its reparse (and its <c>ValidChanged</c>) and keeps the previous
/// script's raw text and parse errors. The page must remount the form per script instead.
/// </summary>
private static readonly FlowScriptOption Manual2 = new("Manual2", "Call", null,
"""{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20);
private void WireInstanceWithTwinScripts()
{
_templates.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }]));
_flow.GetInstanceScriptsAsync(7, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<FlowScriptOption>>([Manual, Manual2, Tick]));
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
private IRenderedComponent<FlowDebuggerPage> ChooseScriptWithAnInvalidParameter()
{
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");
cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
return cut;
}
[Fact]
public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity()
{
RegisterServices();
WireInstanceWithTwinScripts();
var cut = ChooseScriptWithAnInvalidParameter();
cut.Find("select[data-test='flow-script-select']").Change("Manual2");
// A fresh form: no echo of the previous script's text, and no stale parse error keeping
// Run disabled invisibly — Run is disabled only because the required field is unset.
Assert.Equal(string.Empty, cut.Find("#param-n").GetAttribute("value"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-n").Input("5");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun()
{
RegisterServices();
WireInstanceWithTwinScripts();
var cut = ChooseScriptWithAnInvalidParameter();
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"));
}
}