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:
+3
-2
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user