fix(flow-debugger): method pane takes structured parameters via ParameterValueForm + raw-JSON escape hatch (#37)

The Run-inbound-API-method pane rendered one text box per top-level schema
field and coerced the text on the page; an array/object field went through
JsonNode.Parse and, on failure, was SENT AS A STRING, while an empty box was
silently dropped. A required array-of-object parameter (MesMoveOut WorkOrders)
could not be entered at all — QA runs reached the site with "WorkOrders":[].

- The method pane now renders the same shared ParameterValueForm the script
  pane uses (typed inputs, nested groups, add/remove list item editors,
  $ref:lib: resolution, inline validation), keyed on the method id so a change
  of method remounts it. The page keeps ONE value tree and BuildParameters()
  serializes it as-is; the flat _inputs / Coerce() / SetInput path is deleted.
- Form | Raw JSON toggle: raw mode is a Monaco json editor for the whole
  request body, given the method's root schema for completion, parsed and
  schema-validated (InboundApiSchema.Validate) on every edit with the error
  inline. Entering raw mode seeds the editor from the tree; leaving it
  re-mounts the form over the last valid tree.
- Run is disabled while the form or the raw document is invalid (a required
  field left empty disables Run before the first keystroke).
- localStorage per-method entry stores the value tree; on restore it is
  validated against the schema and dropped if invalid, which also retires the
  pre-#37 flat string-per-field entries.
- ParameterValueForm.ToJsonSchema / JsonElementToObject made internal static
  for page reuse.
- Tests: 7 new bUnit tests (WorkOrders array-of-object round-trip to the
  RunMethodAsync payload, required list-item gating, invalid raw JSON blocks
  Run + shows parse/schema/non-object errors, valid raw JSON sent verbatim and
  survives the switch back to the form, method switch resets, persisted tree
  restored, legacy flat entry dropped); existing method-pane tests moved to
  the form's #param-<field> ids and fill the required field before Run.
  Playwright smoke selector moved to #param-Text.
- Docs: design doc, Component-CentralUI.md entry-points bullet, CLAUDE.md.

CentralUI.Tests 1425/1425 green; Playwright project builds.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
Joseph Doherty
2026-09-10 14:54:21 -04:00
parent 3b1211d79f
commit b5eb08f1a9
7 changed files with 422 additions and 94 deletions
@@ -47,9 +47,10 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
await SelectMethodAsync(page);
// The prediction (a Roslyn walk + the route-target resolve) finished: the state chip flips
// to Predicted and the typed input form for the method's parameter appears.
// to Predicted and the method's ParameterValueForm (#37: the same typed form the script
// pane uses, inputs addressed by field id) appears.
await Assertions.Expect(page.Locator("[data-test='flow-state']")).ToHaveTextAsync("Predicted", new() { Timeout = 15_000 });
var input = page.Locator("input[data-test='flow-input-Text']");
var input = page.Locator("#param-Text");
await Assertions.Expect(input).ToBeVisibleAsync();
// @bind commits on change (blur), so commit the value as its own circuit message before Run.
@@ -44,6 +44,23 @@ public class FlowDebuggerPageTests : BunitContext
ParameterDefinitions = """{"type":"object","properties":{"MachineCode":{"type":"string"},"Qty":{"type":"integer"}},"required":["MachineCode"]}""",
};
/// <summary>
/// #37: the MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane
/// could not take at all (the array reached the site empty).
/// </summary>
private static readonly ApiMethod MesMoveOut = new("MesMoveOut", "return 1;")
{
Id = 5,
ParameterDefinitions = """
{"type":"object","properties":{
"SAPID":{"type":"string"},
"WorkOrders":{"type":"array","items":{"type":"object","properties":{
"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"}},
"required":["WorkOrderNumber"]}}},
"required":["SAPID","WorkOrders"]}
""",
};
private static PredictedFlow ThreeNodePrediction() => new(
[
new("n1", FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/DelmiaRecipeDownload", false, null),
@@ -91,11 +108,13 @@ public class FlowDebuggerPageTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
_inboundApi.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>([Delmia]));
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>([Delmia, MesMoveOut]));
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>()).Returns(Delmia);
_inboundApi.GetApiMethodByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MesMoveOut);
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>([new Site("Plant A", "site-a") { Id = 1 }]));
_flow.PredictMethodAsync(4, Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
_flow.PredictMethodAsync(5, Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
Services.AddSingleton(_flow);
Services.AddSingleton(_inboundApi);
@@ -217,9 +236,14 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count));
Assert.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.NotNull(cut.Find("[data-test='flow-predicted-note']"));
Assert.NotNull(cut.Find("input[data-test='flow-input-MachineCode']"));
Assert.NotNull(cut.Find("input[data-test='flow-input-Qty']"));
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
// #37: the method pane is the shared ParameterValueForm (typed inputs by field id), and
// Run is gated on its validity — MachineCode is required, so Run stays off until it is set.
Assert.NotEmpty(cut.FindAll("[data-test='flow-method-params']"));
Assert.NotNull(cut.Find("#param-MachineCode"));
Assert.NotNull(cut.Find("#param-Qty"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-MachineCode").Input("Z28061");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
[Fact]
@@ -231,10 +255,10 @@ public class FlowDebuggerPageTests : BunitContext
.Returns(GraphOutcome(withSite: true, withChildId: true));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("input[data-test='flow-input-MachineCode']").Input("Z28061");
cut.Find("input[data-test='flow-input-Qty']").Input("3");
cut.Find("#param-MachineCode").Input("Z28061");
cut.Find("#param-Qty").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
@@ -258,7 +282,7 @@ public class FlowDebuggerPageTests : BunitContext
.Returns(FlowDebugOutcome.Fail("This node is standby; run the flow on the active central node.", "STANDBY_NODE"));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
@@ -294,7 +318,7 @@ public class FlowDebuggerPageTests : BunitContext
.Returns(GraphOutcome(withSite: false, withChildId: false));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
@@ -347,7 +371,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
@@ -484,7 +508,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
@@ -556,7 +580,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
@@ -578,7 +602,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>();
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
@@ -965,7 +989,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(outcome);
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForElement("[data-test='flow-step-count']");
return cut;
@@ -1170,4 +1194,178 @@ public class FlowDebuggerPageTests : BunitContext
{
Assert.Equal((0, 0, 0, 0), FlowDebuggerPage.CountLogs(null));
}
/// <summary>
/// Waits for the method pane's <c>ParameterValueForm</c> and fills the fixture method's one
/// required field, so Run is enabled (#37 gates Run on the form's validity).
/// </summary>
private static void FillRequiredMethodInput(IRenderedComponent<FlowDebuggerPage> cut)
{
cut.WaitForElement("#param-MachineCode").Input("Z28061");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
// ── #37: structured method parameters ───────────────────────────────────────────────────
[Fact]
public void MethodEntry_ArrayOfObjects_RoundTripsFromItemEditorsToTheRunPayload()
{
RegisterServices();
JsonElement? sent = null;
_flow.RunMethodAsync(5, Arg.Do<JsonElement?>(p => sent = p), Arg.Any<CancellationToken>())
.Returns(GraphOutcome(withSite: true, withChildId: true));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
// Required array unset → Run off. Add two work orders through the list editor.
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-SAPID").Input("103559_A");
cut.Find("button.param-list-add").Click();
cut.Find("button.param-list-add").Click();
cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").Input("WO-1");
cut.Find("[id='param-WorkOrders[0].PartNumber']").Input("P-1");
cut.Find("[id='param-WorkOrders[1].WorkOrderNumber']").Input("WO-2");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
Assert.NotNull(sent);
Assert.Equal("103559_A", sent!.Value.GetProperty("SAPID").GetString());
var orders = sent.Value.GetProperty("WorkOrders");
Assert.Equal(JsonValueKind.Array, orders.ValueKind);
Assert.Equal(2, orders.GetArrayLength());
Assert.Equal("WO-1", orders[0].GetProperty("WorkOrderNumber").GetString());
Assert.Equal("P-1", orders[0].GetProperty("PartNumber").GetString());
Assert.Equal("WO-2", orders[1].GetProperty("WorkOrderNumber").GetString());
}
[Fact]
public void MethodEntry_RequiredListItemField_LeftEmpty_KeepsRunDisabled()
{
RegisterServices();
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
cut.Find("#param-SAPID").Input("103559_A");
cut.Find("button.param-list-add").Click();
// An item exists but its required WorkOrderNumber is unset: still invalid.
cut.Find("[id='param-WorkOrders[0].PartNumber']").Input("P-1");
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").Input("WO-1");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
[Fact]
public async Task MethodEntry_RawJson_InvalidDocument_BlocksRun_AndShowsTheError()
{
RegisterServices();
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
FillRequiredMethodInput(cut);
cut.Find("button[data-test='flow-input-mode-raw']").Click();
var editor = cut.FindComponent<MonacoEditor>();
Assert.Equal("json", editor.Instance.Language);
// Entering raw mode seeds the editor with what the form would have sent.
Assert.Contains("Z28061", editor.Instance.Value);
Assert.Empty(cut.FindAll("[data-test='flow-method-params']"));
// Malformed JSON: no run, inline error.
await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("{\"MachineCode\": "));
cut.WaitForAssertion(() => Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
Assert.Contains("JSON parse error", cut.Find("[data-test='flow-method-raw-error']").TextContent);
// Well-formed but schema-invalid (Qty is an Integer): still no run, schema error shown.
await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("{\"MachineCode\":\"Z1\",\"Qty\":\"three\"}"));
cut.WaitForAssertion(() => Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
Assert.Contains("Qty", cut.Find("[data-test='flow-method-raw-error']").TextContent);
// A non-object document is refused too.
await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("[1,2]"));
cut.WaitForAssertion(() => Assert.Contains("must be a JSON object", cut.Find("[data-test='flow-method-raw-error']").TextContent));
_ = _flow.DidNotReceive().RunMethodAsync(Arg.Any<int>(), Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm()
{
RegisterServices();
JsonElement? sent = null;
_flow.RunMethodAsync(5, Arg.Do<JsonElement?>(p => sent = p), Arg.Any<CancellationToken>())
.Returns(GraphOutcome(withSite: true, withChildId: true));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
cut.Find("button[data-test='flow-input-mode-raw']").Click();
var editor = cut.FindComponent<MonacoEditor>();
const string body = "{\"SAPID\":\"103559_A\",\"WorkOrders\":[{\"WorkOrderNumber\":\"WO-1\",\"PartNumber\":\"P-1\"}]}";
await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync(body));
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
Assert.Empty(cut.FindAll("[data-test='flow-method-raw-error']"));
// Back to the form: the parsed tree is what the item editors now show.
cut.Find("button[data-test='flow-input-mode-form']").Click();
cut.WaitForElement("[id='param-WorkOrders[0].WorkOrderNumber']");
Assert.Equal("WO-1", cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").GetAttribute("value"));
Assert.Equal("103559_A", cut.Find("#param-SAPID").GetAttribute("value"));
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
Assert.NotNull(sent);
Assert.Equal("WO-1", sent!.Value.GetProperty("WorkOrders")[0].GetProperty("WorkOrderNumber").GetString());
Assert.Equal("P-1", sent.Value.GetProperty("WorkOrders")[0].GetProperty("PartNumber").GetString());
}
[Fact]
public void MethodEntry_SwitchingMethods_ResetsTheFormAndItsValidity()
{
RegisterServices();
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
FillRequiredMethodInput(cut);
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
Assert.Empty(cut.FindAll("#param-MachineCode"));
Assert.Equal("", cut.Find("#param-SAPID").GetAttribute("value"));
Assert.Empty(cut.FindAll(".param-list-row"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
[Fact]
public void MethodEntry_PersistedValueTree_IsRestoredIntoTheForm()
{
RegisterServices();
JSInterop.Setup<string?>("localStorage.getItem", "flowDebugger.inputs.4")
.SetResult("{\"MachineCode\":\"Z9\",\"Qty\":7}");
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForAssertion(() => Assert.Equal("Z9", cut.Find("#param-MachineCode").GetAttribute("value")));
Assert.Equal("7", cut.Find("#param-Qty").GetAttribute("value"));
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
[Fact]
public void MethodEntry_LegacyFlatStringEntry_ThatNoLongerValidates_IsDropped()
{
RegisterServices();
// Pre-#37 entries stored every field as a string; "7" is not an Integer, so the whole
// entry is discarded rather than painted into the form as an error.
JSInterop.Setup<string?>("localStorage.getItem", "flowDebugger.inputs.4")
.SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}");
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("#param-MachineCode");
Assert.Equal("", cut.Find("#param-MachineCode").GetAttribute("value"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
}