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:
@@ -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=<id>`** / **`/notifications/report?correlationId=<id>`** (*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).
|
- **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=<id>`** / **`/notifications/report?correlationId=<id>`** (*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.
|
- **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.
|
- **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 `<attribute>`* 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 `<attribute>`* 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.
|
- **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.
|
- **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`.
|
- 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`.
|
||||||
|
|||||||
+39
-10
@@ -214,24 +214,53 @@
|
|||||||
<div class="col-lg-3">
|
<div class="col-lg-3">
|
||||||
<div class="card fd-inputs" data-test="flow-inputs">
|
<div class="card fd-inputs" data-test="flow-inputs">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title text-muted">Inputs</h6>
|
|
||||||
@if (_entry == EntryKind.Method)
|
@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. *@
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="card-title text-muted mb-0">Inputs</h6>
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Input mode">
|
||||||
|
<button type="button" class="btn @(!_methodRawMode ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
data-test="flow-input-mode-form" disabled="@_running" @onclick="() => SetMethodRawMode(false)">Form</button>
|
||||||
|
<button type="button" class="btn @(_methodRawMode ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
data-test="flow-input-mode-raw" disabled="@_running" @onclick="() => SetMethodRawMode(true)">Raw JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_methodRawMode)
|
||||||
{
|
{
|
||||||
var field = f;
|
<div data-test="flow-method-raw">
|
||||||
<div class="mb-2">
|
<MonacoEditor Language="json" Height="220px" ShowToolbar="false"
|
||||||
<label class="form-label form-label-sm">
|
JsonSchema="@MethodRawJsonSchema"
|
||||||
@field.Name
|
Value="@_methodRawText"
|
||||||
<span class="text-muted">(@field.Schema.Type@(field.Required ? ", required" : ""))</span>
|
ValueChanged="@(v => SetMethodRawJson(v))" />
|
||||||
</label>
|
@if (_methodRawError is { } rawError)
|
||||||
<input class="form-control form-control-sm" data-test="@("flow-input-" + field.Name)"
|
{
|
||||||
value="@InputValue(field.Name)" @oninput="e => SetInput(field.Name, e.Value?.ToString())" disabled="@_running" />
|
<div class="text-danger small mt-1" data-test="flow-method-raw-error">@rawError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="form-text">The whole request body, validated against the method's parameter definitions.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div data-test="flow-method-params">
|
||||||
|
<ParameterValueForm @key="@_selectedMethodId"
|
||||||
|
ParameterDefinitions="@_methods.FirstOrDefault(m => m.Id == _selectedMethodId)?.ParameterDefinitions"
|
||||||
|
Values="_methodParams"
|
||||||
|
ValuesChanged="@(v => OnMethodParamsChanged(v))"
|
||||||
|
ValidChanged="@(ok => _methodParamsValid = ok)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (SelectedScript is { } script)
|
else if (SelectedScript is { } script)
|
||||||
{
|
{
|
||||||
|
<h6 class="card-title text-muted">Inputs</h6>
|
||||||
@* A write kind is fired by writing its own trigger attribute — the operator
|
@* 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
|
supplies only the value. Every other kind is called directly with the
|
||||||
parameters its definitions declare. *@
|
parameters its definitions declare. *@
|
||||||
|
|||||||
+164
-64
@@ -1,6 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
@@ -78,6 +77,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
[Inject] private IInboundApiRepository InboundApi { get; set; } = default!;
|
[Inject] private IInboundApiRepository InboundApi { get; set; } = default!;
|
||||||
[Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!;
|
[Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!;
|
||||||
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
|
||||||
|
/// escape hatch validates against the same resolved shape the form renders (#37).
|
||||||
|
/// </summary>
|
||||||
|
[Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!;
|
||||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||||
[Inject] private IDialogService Dialog { get; set; } = default!;
|
[Inject] private IDialogService Dialog { get; set; } = default!;
|
||||||
@@ -149,8 +153,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
private Dictionary<string, object?> _scriptParams = new();
|
private Dictionary<string, object?> _scriptParams = new();
|
||||||
private bool _scriptParamsValid;
|
private bool _scriptParamsValid;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="_methodParams"/>, the same
|
||||||
|
/// coerced value tree <c>ParameterValueForm</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
private InboundApiSchema? _methodSchema;
|
||||||
private List<InboundApiSchemaField> _inputFields = [];
|
private List<InboundApiSchemaField> _inputFields = [];
|
||||||
private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal);
|
private Dictionary<string, object?> _methodParams = new();
|
||||||
|
private bool _methodParamsValid;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="_methodParams"/>, which stays the single source
|
||||||
|
/// of truth in both modes.
|
||||||
|
/// </summary>
|
||||||
|
private bool _methodRawMode;
|
||||||
|
private string _methodRawText = string.Empty;
|
||||||
|
private string? _methodRawError;
|
||||||
|
|
||||||
private PredictedFlow? _predicted;
|
private PredictedFlow? _predicted;
|
||||||
private FlowGraph? _graph;
|
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
|
// 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).
|
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
|
||||||
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
|
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
|
||||||
_entry == EntryKind.Method ? _selectedMethodId > 0
|
_entry == EntryKind.Method ? _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
|
||||||
: SelectedScript is { } s && (s.IsWriteKind
|
: SelectedScript is { } s && (s.IsWriteKind
|
||||||
? !string.IsNullOrWhiteSpace(_valueChangeValue)
|
? !string.IsNullOrWhiteSpace(_valueChangeValue)
|
||||||
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
|
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
|
||||||
@@ -393,6 +416,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
_scriptParams = new();
|
_scriptParams = new();
|
||||||
_scriptParamsValid = false;
|
_scriptParamsValid = false;
|
||||||
_valueChangeValue = string.Empty;
|
_valueChangeValue = string.Empty;
|
||||||
|
ResetMethodInputs();
|
||||||
await PersistAsync(StorageEntry, entry.ToString());
|
await PersistAsync(StorageEntry, entry.ToString());
|
||||||
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
||||||
await OnMethodChangedAsync();
|
await OnMethodChangedAsync();
|
||||||
@@ -403,8 +427,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
private async Task OnMethodChangedAsync()
|
private async Task OnMethodChangedAsync()
|
||||||
{
|
{
|
||||||
ResetResult();
|
ResetResult();
|
||||||
_inputFields = [];
|
ResetMethodInputs();
|
||||||
_inputs.Clear();
|
|
||||||
_predicted = null;
|
_predicted = null;
|
||||||
_state = FlowState.Idle;
|
_state = FlowState.Idle;
|
||||||
|
|
||||||
@@ -412,16 +435,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
if (method == null)
|
if (method == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
_methodSchema = await ResolveMethodSchemaAsync(method.ParameterDefinitions);
|
||||||
{
|
if (_methodSchema is { Type: "object" })
|
||||||
var schema = InboundApiSchema.Parse(method.ParameterDefinitions);
|
_inputFields = _methodSchema.Fields.ToList();
|
||||||
if (schema is { Type: "object" })
|
|
||||||
_inputFields = schema.Fields.ToList();
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
_inputFields = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
await PersistAsync(StorageEntry, EntryKind.Method.ToString());
|
await PersistAsync(StorageEntry, EntryKind.Method.ToString());
|
||||||
await PersistAsync(StorageMethod, method.Id.ToString(CultureInfo.InvariantCulture));
|
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 void OnSelectStep(FlowStep step) => _selected = step;
|
||||||
|
|
||||||
private string InputValue(string name) => _inputs.TryGetValue(name, out var v) ? v : string.Empty;
|
/// <summary>Drops every per-method input: the schema, the value tree, its validity and the raw editor.</summary>
|
||||||
|
private void ResetMethodInputs()
|
||||||
private void SetInput(string name, string? value)
|
|
||||||
{
|
{
|
||||||
if (value == null) _inputs.Remove(name);
|
_methodSchema = null;
|
||||||
else _inputs[name] = value;
|
_inputFields = [];
|
||||||
|
_methodParams = new();
|
||||||
|
_methodParamsValid = false;
|
||||||
|
_methodRawMode = false;
|
||||||
|
_methodRawText = string.Empty;
|
||||||
|
_methodRawError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the request body from the typed inputs, coercing by the schema's declared field
|
/// Parses a method's parameter definitions with <c>$ref:lib:</c> resolution — the same
|
||||||
/// type (integers/numbers/booleans become JSON scalars; objects/lists are parsed as JSON;
|
/// resolution <c>ParameterValueForm</c> applies — so the page's raw-JSON validation and the
|
||||||
/// everything else is a string). Empty inputs are omitted so a non-required field is not
|
/// form agree on the shape. Null when the method declares nothing or the JSON is malformed.
|
||||||
/// sent as an empty string.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal JsonElement? BuildParameters()
|
private async Task<InboundApiSchema?> ResolveMethodSchemaAsync(string? definitions)
|
||||||
{
|
{
|
||||||
if (_inputFields.Count == 0)
|
if (string.IsNullOrWhiteSpace(definitions))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var obj = new JsonObject();
|
Func<string, string?>? resolver = null;
|
||||||
foreach (var f in _inputFields)
|
if (InboundApiSchema.MightContainRef(definitions))
|
||||||
{
|
{
|
||||||
if (!_inputs.TryGetValue(f.Name, out var raw) || string.IsNullOrEmpty(raw))
|
var map = await SchemaLibrary.GetSchemaMapAsync();
|
||||||
continue;
|
resolver = name => map.TryGetValue(name, out var json) ? json : null;
|
||||||
obj[f.Name] = Coerce(f.Schema.Type, raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonSerializer.SerializeToElement(obj);
|
try
|
||||||
|
{
|
||||||
|
return InboundApiSchema.ParseWithRefs(definitions, resolver).Schema;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>JSON Schema text for the raw editor's completion/hover; permissive when there is no schema.</summary>
|
||||||
|
private string MethodRawJsonSchema => _methodSchema is null ? "{}" : ParameterValueForm.ToJsonSchema(_methodSchema);
|
||||||
|
|
||||||
|
private void OnMethodParamsChanged(Dictionary<string, object?> values)
|
||||||
|
{
|
||||||
|
_methodParams = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, object?> ?? new();
|
||||||
|
_methodParamsValid = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> ValidateMethodTree(JsonElement tree)
|
||||||
|
{
|
||||||
|
var errors = new List<string>();
|
||||||
|
_methodSchema?.Validate(tree, string.Empty, errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ParameterValueForm</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal JsonElement? BuildParameters()
|
||||||
|
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
|
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
|
||||||
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
|
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
|
||||||
@@ -946,32 +1065,6 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
internal JsonElement? BuildScriptParameters()
|
internal JsonElement? BuildScriptParameters()
|
||||||
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
|
=> _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)
|
private async Task PersistAsync(string key, string value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -984,29 +1077,36 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Persists the method's value tree (not per-field strings) as a per-method convenience.</summary>
|
||||||
private async Task PersistInputsAsync(int methodId)
|
private async Task PersistInputsAsync(int methodId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture),
|
await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture),
|
||||||
JsonSerializer.Serialize(_inputs));
|
JsonSerializer.Serialize(_methodParams));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private async Task RestoreInputsAsync(int methodId)
|
private async Task RestoreInputsAsync(int methodId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture));
|
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture));
|
||||||
if (string.IsNullOrEmpty(json)) return;
|
if (string.IsNullOrEmpty(json)) return;
|
||||||
var saved = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
if (saved == null) return;
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
|
||||||
foreach (var (k, v) in saved)
|
var root = doc.RootElement.Clone();
|
||||||
if (_inputFields.Any(f => f.Name == k))
|
if (ValidateMethodTree(root).Count > 0) return;
|
||||||
_inputs[k] = v;
|
if (ParameterValueForm.JsonElementToObject(root) is Dictionary<string, object?> tree)
|
||||||
|
_methodParams = tree;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ else
|
|||||||
/// placeholder (Type not in the JSON Schema set) collapses to a permissive
|
/// placeholder (Type not in the JSON Schema set) collapses to a permissive
|
||||||
/// <c>{}</c> so the editor still validates well-formed JSON without false errors.
|
/// <c>{}</c> so the editor still validates well-formed JSON without false errors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string ToJsonSchema(InboundApiSchema schema)
|
internal static string ToJsonSchema(InboundApiSchema schema)
|
||||||
{
|
{
|
||||||
using var stream = new System.IO.MemoryStream();
|
using var stream = new System.IO.MemoryStream();
|
||||||
using (var writer = new System.Text.Json.Utf8JsonWriter(stream))
|
using (var writer = new System.Text.Json.Utf8JsonWriter(stream))
|
||||||
@@ -736,7 +736,7 @@ else
|
|||||||
private static string JoinField(string path, string field) =>
|
private static string JoinField(string path, string field) =>
|
||||||
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
|
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
|
||||||
|
|
||||||
private static object? JsonElementToObject(JsonElement element)
|
internal static object? JsonElementToObject(JsonElement element)
|
||||||
{
|
{
|
||||||
return element.ValueKind switch
|
return element.ValueKind switch
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-2
@@ -47,9 +47,10 @@ public class FlowDebuggerTests : IClassFixture<FlowDebuggerFixture>
|
|||||||
await SelectMethodAsync(page);
|
await SelectMethodAsync(page);
|
||||||
|
|
||||||
// The prediction (a Roslyn walk + the route-target resolve) finished: the state chip flips
|
// 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 });
|
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();
|
await Assertions.Expect(input).ToBeVisibleAsync();
|
||||||
|
|
||||||
// @bind commits on change (blur), so commit the value as its own circuit message before Run.
|
// @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"]}""",
|
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(
|
private static PredictedFlow ThreeNodePrediction() => new(
|
||||||
[
|
[
|
||||||
new("n1", FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/DelmiaRecipeDownload", false, null),
|
new("n1", FlowStepKind.InboundRequest, FlowLane.Central, "POST /api/DelmiaRecipeDownload", false, null),
|
||||||
@@ -91,11 +108,13 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
_inboundApi.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
_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(4, Arg.Any<CancellationToken>()).Returns(Delmia);
|
||||||
|
_inboundApi.GetApiMethodByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MesMoveOut);
|
||||||
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<Site>>([new Site("Plant A", "site-a") { Id = 1 }]));
|
.Returns(Task.FromResult<IReadOnlyList<Site>>([new Site("Plant A", "site-a") { Id = 1 }]));
|
||||||
_flow.PredictMethodAsync(4, Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
|
_flow.PredictMethodAsync(4, Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
|
||||||
|
_flow.PredictMethodAsync(5, Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
|
||||||
|
|
||||||
Services.AddSingleton(_flow);
|
Services.AddSingleton(_flow);
|
||||||
Services.AddSingleton(_inboundApi);
|
Services.AddSingleton(_inboundApi);
|
||||||
@@ -217,9 +236,14 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count));
|
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.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||||
Assert.NotNull(cut.Find("[data-test='flow-predicted-note']"));
|
Assert.NotNull(cut.Find("[data-test='flow-predicted-note']"));
|
||||||
Assert.NotNull(cut.Find("input[data-test='flow-input-MachineCode']"));
|
// #37: the method pane is the shared ParameterValueForm (typed inputs by field id), and
|
||||||
Assert.NotNull(cut.Find("input[data-test='flow-input-Qty']"));
|
// Run is gated on its validity — MachineCode is required, so Run stays off until it is set.
|
||||||
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
|
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]
|
[Fact]
|
||||||
@@ -231,10 +255,10 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
.Returns(GraphOutcome(withSite: true, withChildId: true));
|
.Returns(GraphOutcome(withSite: true, withChildId: true));
|
||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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("#param-MachineCode").Input("Z28061");
|
||||||
cut.Find("input[data-test='flow-input-Qty']").Input("3");
|
cut.Find("#param-Qty").Input("3");
|
||||||
cut.Find("button[data-test='flow-run']").Click();
|
cut.Find("button[data-test='flow-run']").Click();
|
||||||
|
|
||||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
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"));
|
.Returns(FlowDebugOutcome.Fail("This node is standby; run the flow on the active central node.", "STANDBY_NODE"));
|
||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.Find("button[data-test='flow-run']").Click();
|
||||||
|
|
||||||
@@ -294,7 +318,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
.Returns(GraphOutcome(withSite: false, withChildId: false));
|
.Returns(GraphOutcome(withSite: false, withChildId: false));
|
||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.Find("button[data-test='flow-run']").Click();
|
||||||
|
|
||||||
@@ -347,7 +371,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.Find("button[data-test='flow-run']").Click();
|
||||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||||
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
|
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
|
||||||
@@ -484,7 +508,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.Find("button[data-test='flow-run']").Click();
|
||||||
|
|
||||||
@@ -556,7 +580,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.Find("button[data-test='flow-run']").Click();
|
||||||
|
|
||||||
@@ -578,7 +602,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.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);
|
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(outcome);
|
||||||
var cut = Render<FlowDebuggerPage>();
|
var cut = Render<FlowDebuggerPage>();
|
||||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
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.Find("button[data-test='flow-run']").Click();
|
||||||
cut.WaitForElement("[data-test='flow-step-count']");
|
cut.WaitForElement("[data-test='flow-step-count']");
|
||||||
return cut;
|
return cut;
|
||||||
@@ -1170,4 +1194,178 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
{
|
{
|
||||||
Assert.Equal((0, 0, 0, 0), FlowDebuggerPage.CountLogs(null));
|
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