diff --git a/CLAUDE.md b/CLAUDE.md index 8bf3e495..fa66860a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,7 +196,7 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie - Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc. - UI design: Clean, corporate, internal-use aesthetic. Not flashy. Use the `frontend-design` skill when designing UI pages/components. - Debug view: real-time streaming via DebugStreamBridgeActor + gRPC (events via `SiteStreamGrpcClient`, snapshot via the `SiteCommandService` query surface — `QueryReply.DebugViewSnapshot`). Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR. -- **Flow Debugger (2026-09-05, Deployment → `/deployment/flow-debugger` + CLI `flow-debug run-method|run-script|run-value-change|replay`).** Runs one entry point (an inbound API method, or a site script) and draws every step as a Central / Site / Tags / **External** swimlane; *Live* really runs it, *Replay* rebuilds a past execution. **v1.1 (2026-09-06, `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`):** every `ExternalSystem.*` / `Database.*` / `Notify.Send` a traced run makes is an `ApiCall` / `DbCall` / `Notify` node on the External lane — the site routes those already-existing `ApiOutbound` / `DbOutbound` / `Notification` rows through the same inline trace collector, the replay query widens to those channels, and `FlowGraphBuilder` folds a cached operation's rows by `(kind, CorrelationId)` into ONE node with a `Lifecycle` (sync calls in-chain, cached/notify on dashed `Async` edges, trigger cascades on `Spawn` edges, the Response linked from the inbound execution's last step). Later lifecycle rows are not inline, so after a Live run the page polls the Replay command (5 s, ≤ 2 min) while any node is `Pending`, plus a *Refresh outcomes* button. **A refresh MERGES, never replaces (2026-09-06, found live in the browser):** the page unions the re-read rows into the run's own rows by `EventId` and rebuilds the graph, because a Live run carries its site rows inline while the same rows reach central's audit store by telemetry ~5–30 s later — a Replay read taken in between lacks them, and the old wholesale swap made an already-drawn node (a resolved cached DB write) vanish until the next manual refresh. The inline rows ARE the audited rows (`FlowTraceCollector` sinks the same `AuditEvent` objects the site writer persists), so the union is exact. Central method-script `Database.*` calls (`InboundDatabaseHelper`) are now audited (`DbOutbound`/`DbWrite`) — they were not. Rig demo: `docker/seed-flow-outbound-demo.sh` + `verify-delmia-notifier.sh` step I. **Trace steps ARE audit events** — `AuditChannel.ScriptTrace` with kinds `RouteCall` (central, **always-on** per `Route.To().Call`, joins the two halves of a Replay) and `ScriptRun` / `AttributeWrite` / `AttributeWait` (site, **gated** by a `bool Trace` riding beside `ParentExecutionId` on `RouteToCallRequest` / `ScriptCallRequest` / `SetStaticAttributeCommand` / `AttributeValueChanged`; proto fields additive, `site_command.proto` imports `sitestream.proto`). A Live run gets the site rows **inline** on `RouteToCallResponse.TraceEvents` (+ `ChildExecutionId`) because the 5 s telemetry cadence is too slow; Replay reads them from `AuditLog` (`GetTraceEventsAsync`). A value-change on a data-sourced attribute is a REAL tag write; the Instance Actor's **trace arm** (`SiteRuntime:TraceArmWindowSeconds`, 30) stamps the device echo with the writer's execution id so the triggered run is parented and traced, and central polls the site's new `PullTraceEvents` RPC (point read, not reconciliation) for it. Execution path is four management commands on the **per-node `ManagementActor`** (`FlowDebug*`; the three run commands are Deployer-only, Replay is wider) — a standby refuses the three run commands with **`STANDBY_NODE`** (shown verbatim); `ApiMethod.TraceAllCalls` (migration `AddApiMethodTraceAllCalls`, CLI `--trace-all-calls`) traces every real call to a method for later Replay (7–10 rows/call). Prediction before a run is a Roslyn syntax walk (`FlowShapePredictor`, REQ-SA-7, DB-free); the graph (`FlowStep`/`FlowGraphBuilder`, Commons) is a central projection, never a wire type; the renderer is custom SVG (`FlowGraphView`). Rig: `AuditLog:PerChannelRetentionDays:ScriptTrace=30` on both centrals; `verify-delmia-notifier.sh` step H proves live + replay. **Mixed-version caveat:** an older site ignores the flag → central `RouteCall` only, page says *site not traced*. **v1.2 (2026-09-06, `docs/plans/2026-09-06-flow-debugger-followups.md`):** a `Parked` External node offers **Retry / Discard in place** (same site-scope check, confirm and relay as the report pages, via `IFlowDebugService.RetryOutboundAsync`/`DiscardOutboundAsync`, then the page re-polls Replay until the node leaves Parked) and every folded node deep-links to `/site-calls/report?correlationId=` / `/notifications/report?correlationId=` (both pages accept it through additive `TrackedOperationIdFilter` / `NotificationIdFilter` query params and auto-open the row); register row 111 closed. **Trap closed the same day:** `FlowDebugService.PredictMethodAsync` used to resolve route targets with `GetAwaiter().GetResult()` inside the synchronous predictor walk — on the Blazor renderer sync context that DEADLOCKED the whole circuit (page inert, healthy websocket, no error) whenever a method routing to a site script was chosen; now a resolve-and-rewalk loop, pinned by a single-thread-pump test. Never block on a task from a Blazor event/lifecycle path. Browser checks of the page go through Playwright (`FlowDebuggerTests`, fixture login + an ephemeral method), not the login-disabled override. **Arch-reviewed 2026-09-06** (`docs/plans/2026-09-06-flow-debugger-arch-review.md`, remediation `docs/plans/2026-09-06-flow-debugger-arch-review-remediation.md`): the inline Live trace copy is now redacted and capped by the shared `FlowTraceCollector` (500 events **and** 1 MB of `DetailsJson`, F1), a method-entry Live run enforces the operator's site scope at route time (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED` → `ManagementUnauthorized`, F2), and the page/Replay opened to the `OperationalAudit` roles under the new `RequireFlowDebugger` policy while everything that executes stays Deployer-only (F3). Design `docs/plans/2026-09-05-flow-debugger-design.md`; plan `docs/plans/2026-09-05-flow-debugger.md`. **v1.3 (2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`):** the second entry point is site → instance → **script**; ValueChange/Conditional scripts are fired by writing the trigger's attribute (derived from the trigger config, not picked), every other kind is a direct traced `RouteToCall` with a `ParameterValueForm` from the script's `ParameterDefinitions` and one operator-attributed `RouteCall` root row (`FlowDebugRunScriptCommand`, Deployer-only, long-running); `run-value-change` stays as the attribute-level CLI escape hatch (fires Expression triggers). **Instance picker (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`):** Flow Debugger and Debug View both replaced their flat instance `` with the shared **`InstancePicker`** combobox — a text field whose dropdown is the site's area tree (`InstanceTreeBuilder`, all areas collapsed on open), typed text filters and expands to the matches; `Areas`/`Instances` are compared by reference, so callers must assign the loaded lists once per site change, never per render, or the panel closes on every re-render. **v1.4 (2026-09-09, `docs/plans/2026-09-09-flow-debugger-step-logs-design.md`):** scripts can log — a `Log.Debug/Info/Warn/Error(message, exception?)` global on all four surfaces (site `ScriptGlobals`, inbound `InboundScriptContext`, the `ScriptCompileSurface` deploy gate as `CompileLog`, and both Test Run sandboxes/editor hosts, where it appends to the console pane), always reaching the node `ILogger` and emitting a `ScriptTrace`/**`ScriptLog`** audit row (one per line) **only when the run is traced**, so an untraced timer script costs no audit volume. `FlowGraphBuilder` **folds those rows onto the step that produced them** (`FlowStep.Logs`) and never draws them as nodes — attribution is `Extra.stepEventId`, pre-minted by `SetAttribute`/`WriteBatchAndWait`/`WaitInternal`/sync `ExternalSystem.Call` through an `AsyncLocal` `TraceStepScope`, else the execution's anchor row, else its first step, else `FlowGraph.UnattachedLogLines`; the runtime's own Information+ entries are captured for free by a `TraceCapturingLogger` decorator instead of editing every `_logger.LogWarning` call site. The failing step now carries the **whole exception** (`errorDetail = ex.ToString()` on the site `ScriptRun` row and on the central inbound row — the latter never on the HTTP body, which stays `SCRIPT_ERROR`). **The two halves are NOT gated alike:** the central inbound row's `errorDetail` is stamped on **every failing method request, traced or not** (`EndpointExtensions` stashes it on `HttpContext.Items`, `AuditWriteMiddleware` consumes it), bounded only by the redactor's `ApiInbound` cap `AuditLog:InboundMaxBytes` (1 MiB default; the 64 KB `ErrorCapBytes` is what bounds every OTHER channel's error rows, the site `ScriptRun` row included) — tracing only decides whether the row is later readable as a Flow Debugger step; the site `ScriptRun` `errorDetail` is **traced-only**, because an untraced run emits no `ScriptTrace` row at all, rendered as a red `Type: message` subtitle plus a **Log (n)** tab; the node badge is `✕ n ! n ≡ n` and the strip counts `Log: n errors · n warnings · n info` (a `Log.Error` is a line, not a failed step — it never moves the state chip). Bounded: `ScadaBridge:SiteRuntime:MaxLogLinesPerRun` / `ScadaBridge:InboundApi:MaxLogLinesPerRun` (200, validated > 0), 2 000-char messages, one closing `warn`/`runtime` row naming the dropped count, and a **low-priority inline lane** (`FlowTraceCollector.Add(evt, lowPriority: true)`, log rows stop 50 events short of the count cap and at 75 % of the byte budget, reserving the rest for the step rows written at run end) whose overflow sets `LogsTruncated` → the page badge *inline log lines capped — refresh outcomes pulls the rest*, which clears once a refresh merges in more log rows. **"Never `await` an audit write on a script path" still holds** — every log row is enqueued fire-and-continue, exactly like the 2026-09-06 cached-call telemetry fix. **Trap:** build `ScriptLogger` over the UNDECORATED logger — `TraceCapturingLogger` refuses to wrap another `TraceCapturingLogger` (it would emit two rows per line). CLI `flow-debug … --show-logs` + a `logs` column; rig demo `docker/seed-flow-log-demo.sh` + `verify-delmia-notifier.sh` step J. Mixed-version: an OLD central projects a NEW site's `ScriptLog` rows as `Unknown` nodes — upgrade central first, as always. **Method pane structured inputs (2026-09-10, Gitea #37, `docs/plans/2026-09-10-flow-debugger-method-params-design.md`):** the *Run inbound API method* pane now renders the same `ParameterValueForm` as the script pane (keyed on the method id) plus a **Form | Raw JSON** whole-payload escape hatch (Monaco, schema-validated on every edit); the page keeps ONE value tree and serializes it as-is, the old per-field text coercion is gone, and Run is disabled while the form or the raw document is invalid — so a required array-of-object parameter (MesMoveOut `WorkOrders`) can be entered, and a failed parse is never sent as a string. The per-method `localStorage` entry stores the value tree and is dropped on restore if it no longer validates (that also retires the pre-#37 flat string entries). - Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval. - Dead letter monitoring as a health metric. - Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search. **The cap measures `site_events` + its four indexes, NOT the consolidated LocalDb file** (register row 81, 2026-08-17): whole-file measurement made the trim unwinnable on a replicated node — each batch freed event pages while adding an equal count of oplog rows + tombstones to the same file, so it stopped 2.5x over a 64 MB cap and a node with a down peer could not enforce the cap at all. `dbstat` (the exact per-table measurement) is **NOT compiled into the bundled `SQLitePCLRaw.lib.e_sqlite3`** — probed and pinned by `SqliteCapabilityTests`, so don't reach for it — the footprint is modelled from sampled row width instead (−0.8%…+12.8%, biased high). Consequence: `MaxStorageMb` no longer bounds the data volume alone; LocalDb bookkeeping is separate overhead bounded by `MaxOplogRows`/`TombstoneRetention` and is deliberately not this component's job. Decision: `docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md`. diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 43f96d3b..9b9c8c5e 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -231,7 +231,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a **bolt badge on the connector's riser** — the edge itself keeps an ordinary arrowhead (the bolt was the arrowhead until 2026-09-08, and on the upward Tags→Site hop it read as a broken arrow). The spawn edge starts at the parent's **last write of the attribute the child's `triggerAttribute` names** (`FlowGraphBuilder`); only when the child names no attribute, or the parent never wrote it, does the timestamp rule (last non-async parent step at or before the child) decide — a burst of writes landing in one millisecond used to draw the cascade from the last of them rather than the one that fired it. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=`** / **`/notifications/report?correlationId=`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser). - **Step logs and exceptions (v1.4, 2026-09-09, design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md`).** A traced run shows, **on the node that produced them**, the exception a failing step threw and every line the script (or the runtime on its behalf) logged. A log line is an ordinary audit row — `ScriptTrace` / `ScriptLog`, one per line — that `FlowGraphBuilder` folds into its owning step's `FlowStep.Logs` and **never draws as a node**; attribution is `Extra.stepEventId` when the emitting helper pre-minted its step id, else the execution's anchor row (`ScriptRun` on a site, `InboundRequest` / the debugger's synthesized `RouteCall` root centrally), else that execution's first step, else `FlowGraph.UnattachedLogLines`. Rendering: a node footer badge (`✕ n` errors, `! n` warnings, `≡ n` info, highest severity first, `data-test="flow-node-logs"`, hidden with no lines); a failed node's subtitle becomes `ExceptionType: message` (namespace stripped, innermost exception, generic-safe) fed by the new `FlowStep.ErrorDetail`; a **Log (n)** tab in the detail panel (`flow-step-tab-log`) carrying the full exception block first, then the lines under an `All | Warn +` filter with a copy button and a *"n more on <step>"* cross-step hint that selects the named step; and a `Log: n errors · n warnings · n info` count in the status strip (`flow-log-counts`) that counts LINES, not steps — a `Log.Error` line is not a failed step and never moves the state chip. Two truncation notes sit beside it: `flow-logs-truncated` (*inline log lines capped — refresh outcomes pulls the rest*, from `FlowDebugRunResult.LogsTruncated`; it clears once a refresh merges in more log rows, because Replay reads the complete set) and `flow-unattached-logs`. Live and Replay read the same rows, so the two modes render identically once telemetry has landed. - **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the three run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` / `?script=` drill-ins; for a non-Deployer the page pins itself to Replay mode. -- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for ``* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. Both entry points pick the target instance through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder` — all areas collapsed on open, un-areaed instances at the root, orphaned rows surface at the root), typed text filters by instance name and expands to the matches, Enter picks the first visible match, Escape restores the selection. +- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (**the same `ParameterValueForm` the script pane uses**, generated from the method's `ParameterDefinitions` — typed inputs, nested object groups, add/remove list item editors, `$ref:lib:` resolution, inline validation — plus a **Form | Raw JSON** toggle whose raw mode is a Monaco JSON editor for the whole request body, parsed and validated against the method's schema on every edit; Run is disabled while either surface is invalid, and a failed parse is never sent — Gitea #37, `docs/plans/2026-09-10-flow-debugger-method-params-design.md`; the pre-#37 pane was one text box per top-level field with page-side coercion, which could not take an array-of-object parameter at all) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for ``* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. Both entry points pick the target instance through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder` — all areas collapsed on open, un-areaed instances at the root, orphaned rows surface at the root), typed text filters by instance name and expands to the matches, Enter picks the first visible match, Escape restores the selection. - **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed. - **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the three run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same four commands back the CLI's `flow-debug run-method | run-script | run-value-change | replay`. **The client wait is derived from the server's own bound, not a fixed duration (arch-review F4):** `FlowDebugService.RunAskTimeoutFor(timeoutSeconds)` = the method's `TimeoutSeconds` + the server's `FlowDebugRunner.RunGrace` (10 s) + 15 s client slack, floored at 30 s, and `ValueChangeAskTimeoutFor(waitSeconds)` = the trigger-wait window + 15 s (Replay stays a flat 30 s); the CLI mirrors it with `run-method --timeout-seconds` (default 120, set it to the method's timeout + 25 for long methods) and a `run-value-change` wait of `--wait-seconds + 25`. A long-running method used to be cut off client-side before the server's own grace expired. **The script entry point (2026-09-08) reuses the same two timeout functions by kind:** a call-kind script run waits `RunAskTimeoutFor(script.ExecutionTimeoutSeconds ?? 30)`, a write-kind script run waits `ValueChangeAskTimeoutFor(45)`; the page's `?instanceId=` + `?script=` drill-in replaces the former `?attribute=` query parameter. - State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index c3fa2338..5ea028d8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -214,24 +214,53 @@
-
Inputs
@if (_entry == EntryKind.Method) { - @foreach (var f in _inputFields) + @* Method entry (#37): the same typed ParameterValueForm the script pane uses — + per-item list editors, nested object groups, inline validation — keyed on + the method so a change of method remounts it; plus a whole-payload raw-JSON + escape hatch to paste the exact body an external caller sends. Both edit + one value tree, and Run stays disabled while it is invalid. *@ +
+
Inputs
+
+ + +
+
+ @if (_methodRawMode) { - var field = f; -
- - +
+ + @if (_methodRawError is { } rawError) + { +
@rawError
+ } + else + { +
The whole request body, validated against the method's parameter definitions.
+ } +
+ } + else + { +
+
} } else if (SelectedScript is { } script) { +
Inputs
@* A write kind is fired by writing its own trigger attribute — the operator supplies only the value. Every other kind is called directly with the parameters its definitions declare. *@ diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index c144a3ba..3de0a4f0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Text.Json; -using System.Text.Json.Nodes; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.JSInterop; @@ -78,6 +77,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable [Inject] private IInboundApiRepository InboundApi { get; set; } = default!; [Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!; [Inject] private ISiteRepository SiteRepository { get; set; } = default!; + /// + /// Resolves {"$ref":"lib:Name"} nodes in a method's parameter schema so the raw-JSON + /// escape hatch validates against the same resolved shape the form renders (#37). + /// + [Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!; [Inject] private SiteScopeService SiteScope { get; set; } = default!; [Inject] private IJSRuntime JS { get; set; } = default!; [Inject] private IDialogService Dialog { get; set; } = default!; @@ -149,8 +153,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable private Dictionary _scriptParams = new(); private bool _scriptParamsValid; + /// + /// Method entry (#37): the chosen method's resolved root schema (null when it declares no + /// parameters or the definitions do not parse) and its top-level fields — the card renders + /// only when there is at least one. The values live in , the same + /// coerced value tree ParameterValueForm emits for the script pane, so the request + /// body is that tree serialized as-is: no per-field text coercion on the page any more. + /// + private InboundApiSchema? _methodSchema; private List _inputFields = []; - private readonly Dictionary _inputs = new(StringComparer.Ordinal); + private Dictionary _methodParams = new(); + private bool _methodParamsValid; + + /// + /// Raw-JSON escape hatch for the whole method payload (#37): paste the exact body an + /// external caller would send. The text is parsed and schema-validated on every edit; + /// only a valid document reaches , which stays the single source + /// of truth in both modes. + /// + private bool _methodRawMode; + private string _methodRawText = string.Empty; + private string? _methodRawError; private PredictedFlow? _predicted; private FlowGraph? _graph; @@ -231,7 +254,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable // 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 && ( - _entry == EntryKind.Method ? _selectedMethodId > 0 + _entry == EntryKind.Method ? _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid) : SelectedScript is { } s && (s.IsWriteKind ? !string.IsNullOrWhiteSpace(_valueChangeValue) : string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid)); @@ -393,6 +416,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable _scriptParams = new(); _scriptParamsValid = false; _valueChangeValue = string.Empty; + ResetMethodInputs(); await PersistAsync(StorageEntry, entry.ToString()); if (_entry == EntryKind.Method && _selectedMethodId > 0) await OnMethodChangedAsync(); @@ -403,8 +427,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable private async Task OnMethodChangedAsync() { ResetResult(); - _inputFields = []; - _inputs.Clear(); + ResetMethodInputs(); _predicted = null; _state = FlowState.Idle; @@ -412,16 +435,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable if (method == null) return; - try - { - var schema = InboundApiSchema.Parse(method.ParameterDefinitions); - if (schema is { Type: "object" }) - _inputFields = schema.Fields.ToList(); - } - catch (JsonException) - { - _inputFields = []; - } + _methodSchema = await ResolveMethodSchemaAsync(method.ParameterDefinitions); + if (_methodSchema is { Type: "object" }) + _inputFields = _methodSchema.Fields.ToList(); await PersistAsync(StorageEntry, EntryKind.Method.ToString()); await PersistAsync(StorageMethod, method.Id.ToString(CultureInfo.InvariantCulture)); @@ -908,36 +924,139 @@ public partial class FlowDebugger : ComponentBase, IDisposable private void OnSelectStep(FlowStep step) => _selected = step; - private string InputValue(string name) => _inputs.TryGetValue(name, out var v) ? v : string.Empty; - - private void SetInput(string name, string? value) + /// Drops every per-method input: the schema, the value tree, its validity and the raw editor. + private void ResetMethodInputs() { - if (value == null) _inputs.Remove(name); - else _inputs[name] = value; + _methodSchema = null; + _inputFields = []; + _methodParams = new(); + _methodParamsValid = false; + _methodRawMode = false; + _methodRawText = string.Empty; + _methodRawError = null; } /// - /// Builds the request body from the typed inputs, coercing by the schema's declared field - /// type (integers/numbers/booleans become JSON scalars; objects/lists are parsed as JSON; - /// everything else is a string). Empty inputs are omitted so a non-required field is not - /// sent as an empty string. + /// Parses a method's parameter definitions with $ref:lib: resolution — the same + /// resolution ParameterValueForm applies — so the page's raw-JSON validation and the + /// form agree on the shape. Null when the method declares nothing or the JSON is malformed. /// - internal JsonElement? BuildParameters() + private async Task ResolveMethodSchemaAsync(string? definitions) { - if (_inputFields.Count == 0) + if (string.IsNullOrWhiteSpace(definitions)) return null; - var obj = new JsonObject(); - foreach (var f in _inputFields) + Func? resolver = null; + if (InboundApiSchema.MightContainRef(definitions)) { - if (!_inputs.TryGetValue(f.Name, out var raw) || string.IsNullOrEmpty(raw)) - continue; - obj[f.Name] = Coerce(f.Schema.Type, raw); + var map = await SchemaLibrary.GetSchemaMapAsync(); + resolver = name => map.TryGetValue(name, out var json) ? json : null; } - return JsonSerializer.SerializeToElement(obj); + try + { + return InboundApiSchema.ParseWithRefs(definitions, resolver).Schema; + } + catch (JsonException) + { + return null; + } } + /// JSON Schema text for the raw editor's completion/hover; permissive when there is no schema. + private string MethodRawJsonSchema => _methodSchema is null ? "{}" : ParameterValueForm.ToJsonSchema(_methodSchema); + + private void OnMethodParamsChanged(Dictionary values) + { + _methodParams = values; + } + + /// + /// Form ⇄ Raw JSON (#37). Entering raw mode seeds the editor from the current value tree so + /// the operator edits what the form would have sent; leaving it re-mounts the form over the + /// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is + /// on, so it mounts fresh and re-reports validity from the tree). + /// + private void SetMethodRawMode(bool raw) + { + if (_methodRawMode == raw) return; + _methodRawMode = raw; + _methodRawError = null; + if (raw) + { + _methodRawText = _methodParams.Count == 0 + ? "{}" + : JsonSerializer.Serialize(_methodParams, RawJsonWriteOptions); + _methodParamsValid = ValidateMethodTree(JsonSerializer.SerializeToElement(_methodParams)).Count == 0; + } + } + + private static readonly JsonSerializerOptions RawJsonWriteOptions = new() { WriteIndented = true }; + + /// + /// Raw editor edit: parse, require a JSON object, validate against the resolved schema. + /// A document that fails at any stage leaves the previous tree untouched and disables Run — + /// a failed parse is never sent, as a string or otherwise. + /// + private void SetMethodRawJson(string? text) + { + _methodRawText = text ?? string.Empty; + if (string.IsNullOrWhiteSpace(_methodRawText)) + { + _methodRawError = "Enter the request body as a JSON object."; + _methodParamsValid = false; + return; + } + + JsonElement root; + try + { + using var doc = JsonDocument.Parse(_methodRawText); + root = doc.RootElement.Clone(); + } + catch (JsonException ex) + { + _methodRawError = "JSON parse error: " + ex.Message; + _methodParamsValid = false; + return; + } + + if (root.ValueKind != JsonValueKind.Object) + { + _methodRawError = "The request body must be a JSON object."; + _methodParamsValid = false; + return; + } + + var errors = ValidateMethodTree(root); + if (errors.Count > 0) + { + _methodRawError = string.Join(" ", errors); + _methodParamsValid = false; + return; + } + + _methodRawError = null; + _methodParams = ParameterValueForm.JsonElementToObject(root) as Dictionary ?? new(); + _methodParamsValid = true; + } + + private List ValidateMethodTree(JsonElement tree) + { + var errors = new List(); + _methodSchema?.Validate(tree, string.Empty, errors); + return errors; + } + + /// + /// The request body for a method-entry run: the value tree as-is (scalars as + /// bool/long/double/string, objects as dictionaries, lists as lists — exactly what + /// ParameterValueForm emits and what the raw editor parsed), or null when the method + /// declares no parameters. Run is gated on validity, so nothing here re-checks the tree. + /// + internal JsonElement? BuildParameters() + => _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams); + /// /// The call-kind script's parameters as a flat JSON object, or null when the form produced /// nothing. ParameterValueForm already emits the coerced value tree (scalars as @@ -946,32 +1065,6 @@ public partial class FlowDebugger : ComponentBase, IDisposable internal JsonElement? BuildScriptParameters() => _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams); - private static JsonNode? Coerce(string? type, string raw) - { - switch (InboundApiSchema.NormalizeType(type)) - { - case "integer": - return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) ? JsonValue.Create(l) : JsonValue.Create(raw); - case "number": - return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? JsonValue.Create(d) : JsonValue.Create(raw); - case "boolean": - return bool.TryParse(raw, out var b) ? JsonValue.Create(b) : JsonValue.Create(raw); - case "object": - case "array": - case "list": - try - { - return JsonNode.Parse(raw); - } - catch (JsonException) - { - return JsonValue.Create(raw); - } - default: - return JsonValue.Create(raw); - } - } - private async Task PersistAsync(string key, string value) { try @@ -984,29 +1077,36 @@ public partial class FlowDebugger : ComponentBase, IDisposable } } + /// Persists the method's value tree (not per-field strings) as a per-method convenience. private async Task PersistInputsAsync(int methodId) { try { await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture), - JsonSerializer.Serialize(_inputs)); + JsonSerializer.Serialize(_methodParams)); } catch (Exception) { } } + /// + /// Restores a persisted value tree only when it still validates against the method's current + /// schema — a stale tree (the schema changed, or the pre-#37 flat string-per-field entry) is + /// dropped rather than painted into the form as errors. + /// private async Task RestoreInputsAsync(int methodId) { try { var json = await JS.InvokeAsync("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture)); if (string.IsNullOrEmpty(json)) return; - var saved = JsonSerializer.Deserialize>(json); - if (saved == null) return; - foreach (var (k, v) in saved) - if (_inputFields.Any(f => f.Name == k)) - _inputs[k] = v; + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return; + var root = doc.RootElement.Clone(); + if (ValidateMethodTree(root).Count > 0) return; + if (ParameterValueForm.JsonElementToObject(root) is Dictionary tree) + _methodParams = tree; } catch (Exception) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor index 0d1ab5f3..12ebe259 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor @@ -139,7 +139,7 @@ else /// placeholder (Type not in the JSON Schema set) collapses to a permissive /// {} so the editor still validates well-formed JSON without false errors. /// - private static string ToJsonSchema(InboundApiSchema schema) + internal static string ToJsonSchema(InboundApiSchema schema) { using var stream = new System.IO.MemoryStream(); using (var writer = new System.Text.Json.Utf8JsonWriter(stream)) @@ -736,7 +736,7 @@ else private static string JoinField(string path, string field) => string.IsNullOrEmpty(path) ? field : $"{path}.{field}"; - private static object? JsonElementToObject(JsonElement element) + internal static object? JsonElementToObject(JsonElement element) { return element.ValueKind switch { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs index 0a00613c..817880d2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs @@ -47,9 +47,10 @@ public class FlowDebuggerTests : IClassFixture 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. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index b280df72..23ed81b4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -44,6 +44,23 @@ public class FlowDebuggerPageTests : BunitContext ParameterDefinitions = """{"type":"object","properties":{"MachineCode":{"type":"string"},"Qty":{"type":"integer"}},"required":["MachineCode"]}""", }; + /// + /// #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). + /// + 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()) - .Returns(Task.FromResult>([Delmia])); + .Returns(Task.FromResult>([Delmia, MesMoveOut])); _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()).Returns(Delmia); + _inboundApi.GetApiMethodByIdAsync(5, Arg.Any()).Returns(MesMoveOut); _sites.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>([new Site("Plant A", "site-a") { Id = 1 }])); _flow.PredictMethodAsync(4, Arg.Any()).Returns(ThreeNodePrediction()); + _flow.PredictMethodAsync(5, Arg.Any()).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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(), Arg.Any()).Returns(outcome); var cut = Render(); 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)); } + + /// + /// Waits for the method pane's ParameterValueForm and fills the fixture method's one + /// required field, so Run is enabled (#37 gates Run on the form's validity). + /// + private static void FillRequiredMethodInput(IRenderedComponent 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(p => sent = p), Arg.Any()) + .Returns(GraphOutcome(withSite: true, withChildId: true)); + var cut = Render(); + 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(); + 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(); + 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(); + 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(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm() + { + RegisterServices(); + JsonElement? sent = null; + _flow.RunMethodAsync(5, Arg.Do(p => sent = p), Arg.Any()) + .Returns(GraphOutcome(withSite: true, withChildId: true)); + var cut = Render(); + 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(); + 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(); + 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("localStorage.getItem", "flowDebugger.inputs.4") + .SetResult("{\"MachineCode\":\"Z9\",\"Qty\":7}"); + var cut = Render(); + 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("localStorage.getItem", "flowDebugger.inputs.4") + .SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}"); + var cut = Render(); + 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")); + } }