diff --git a/CLAUDE.md b/CLAUDE.md index 1dbb4cdd..56d6923e 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). +- **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 `` instance pickers on `/deployment/flow-debugger` (`flow-instance-select`) and `/deployment/debug-view` (`debug-instance-select`). + +## 1. Change + +Both pages pick an instance from a flat, alphabetical `` becomes `` (the handler sets the id then awaits the unchanged `OnInstanceChangedAsync`). **The picker stays disabled while the site's instances load** (`_instancesLoading`, both pages): Blazor renders at the handler's first `await`, so without the flag the field is enabled while the old lists are still bound, and text typed in that window is wiped when the loaded lists arrive (the reference change reads as a site change) — found by Playwright on the rig. `_selectedInstanceId`, `OnInstanceChangedAsync`, the `?instanceId=` deep link, localStorage restore and the site-scope gate are unchanged. +- **Debug View** (`DebugView.razor`): `LoadInstancesForSite` also loads the areas; the ``+option to typing in `{prefix}-input` and clicking the row. + +## 6. Docs + +`docs/requirements/Component-CentralUI.md` (Flow Debugger + Debug View paragraphs), `CLAUDE.md` Flow Debugger bullet (one sentence), this design doc. diff --git a/docs/plans/2026-09-08-instance-picker.md b/docs/plans/2026-09-08-instance-picker.md new file mode 100644 index 00000000..f829190a --- /dev/null +++ b/docs/plans/2026-09-08-instance-picker.md @@ -0,0 +1,261 @@ +# Instance Picker Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the flat instance ` + + + @if (_open) + { +
+
+ + + + @node.Label + + @(_hasFilter ? "No instances match." : "No instances at this site.") + +
+ } + +``` + +Code-behind essentials: +- Parameters per design §2 table (`Areas`, `Instances`, `SelectedInstanceId`, `SelectedInstanceIdChanged`, `Disabled`, `ItemLabel`, `DataTestPrefix` (default `"instance"`), `Placeholder` (default `"Search or browse instances…"`)). +- State: `_open`, `_text`, `_filter`, `_roots`, `_tree` (`TreeView?`), `_inputId`/`_panelId` from a `Guid` suffix. +- `OnParametersSet`: if `Areas`/`Instances` references changed → rebuild `_roots` with the current filter and (if the selection is no longer in `Instances`) clear text; if `SelectedInstanceId` changed and the panel is closed → `_text = LabelOf(selected)`. +- `Rebuild()` → `InstanceTreeBuilder.Build(Areas, Instances, _filter)` — the label used for matching is `UniqueName` (the builder's contract), rows display `ItemLabel`. +- `OnRowClickAsync(node)`: area → `_tree.ToggleExpand`? (`TreeView` has no public toggle — expose one: add `public void ToggleExpanded(object key)` to `TreeView.razor` next to `IsExpanded`, a one-line wrapper around the existing private `ToggleExpand`); instance → `SelectAsync(node.Instance)`. +- `SelectAsync(inst)`: `_text = LabelOf(inst)`, `_filter = ""`, close, `await SelectedInstanceIdChanged.InvokeAsync(inst.Id)`. +- `CloseAsync`: `_open = false`, `_filter = ""`, `_text = LabelOf(current)`. +- Guard: when `Disabled`, `OpenAsync`/`ToggleAsync` no-op. + +CSS (`InstancePicker.razor.css`): `.ip-backdrop { position: fixed; inset: 0; z-index: 1040; background: transparent; }` `.ip-panel { position: absolute; left: 0; right: 0; top: 100%; z-index: 1041; max-height: 40vh; overflow-y: auto; min-width: 18rem; padding: .25rem; }` `.ip-instance { cursor: pointer; }` `.ip-area { cursor: pointer; }`. Rows render inside `TreeView`, whose `tv-*` classes are already global to that component — check `TreeView.razor.css` whether `tv-label`/`tv-glyph` are `::deep`-reachable from a consumer (TemplatePickerDialog uses them the same way, so follow that). + +**Step 4: tests → PASS. Step 5: Commit** `feat(central-ui): InstancePicker — combobox over the site's area tree`. + +--- + +### Task 3: `InstancePicker` — search filter + keyboard + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor` / `.razor.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs` + +**Step 1: Failing tests** + +- `Typing_Filters_AndExpandsToTheMatches`: open, `Input("p1")` on the input → `[data-instance-id='7']` is rendered (ancestors expanded), Zulu row absent, Line1 row present. +- `Clearing_RestoresTheFullTree_Collapsed`: after the above, `Input("")` → Zulu back, P1 hidden again (Line1 collapsed). +- `Typing_OpensTheClosedPanel`. +- `Enter_PicksTheFirstVisibleMatch`: type "u" (matches Zulu only) + `KeyDown(Key="Enter")` → callback 8, closed, text "Zulu"; with no match Enter does nothing (still open). +- `Escape_ClosesAndRestoresTheSelectedText`: select P1, open, type "zzz", Escape → closed, text "P1". +- `ArrowDown_MovesFocusIntoTheTree`: after ArrowDown the first visible `[role='treeitem']` has `tabindex="0"` (the tree's roving tabindex target) — assert via the `TreeView` markup; focus itself is JS (Loose mode), so assert the call happened through `JSInterop` if `TreeView` uses `FocusAsync` (an `ElementReference.FocusAsync` shows up as a `Blazor._internal.domWrapper.focus` invocation in bUnit — assert `JSInterop.Invocations` contains it, or expose a `public Task FocusFirstVisibleAsync()` on `TreeView` and assert it doesn't throw). + +**Step 2: run → fail. Step 3: Implement** + +- `OnInput(ChangeEventArgs e)`: `_text = e.Value?.ToString() ?? ""`, `_filter = _text`, `_open = true`, `Rebuild()`; after render, if `_hasFilter` → `_tree?.ExpandAll()` else `_tree?.CollapseAll()` (do it in `OnAfterRender` guarded by a `_pendingExpand` flag so the tree instance exists). +- `OnInputKeyDown(KeyboardEventArgs e)`: `Enter` → first instance node in document order of the current (filtered) `_roots` → `SelectAsync`; `Escape` → `CloseAsync`; `ArrowDown` → `_open = true` and focus the tree's first visible item (`TreeView`: add `public async Task FocusFirstVisibleAsync()` that sets `_focusedKey` to the first visible node key and applies focus via the existing `_focusNeedsApply` mechanism). +- Typing on a closed picker opens it. + +**Step 4: PASS. Step 5: Commit** `feat(central-ui): InstancePicker search filter + keyboard (Enter/Escape/ArrowDown)`. + +--- + +### Task 4: Wire Flow Debugger to the picker + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (~lines 62–71, the instance `` ~lines 83–92; `_siteInstances` ~259; `LoadInstancesForSite` ~430–452) +- Test: create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs` (copy the service registration from `DebugViewDisposalTests.cs`) + +**Steps:** (1) Test: substitute repo returns one site, instances [Enabled "Sim01" id 7 in area 2] + areas [Line1 (1) → Cell A (2)]; render `DebugView`, `Change` the site select to the site, open `[data-test='debug-instance-toggle']` → only the Line1 row; expand twice → row `[data-instance-id='7']` text `"Sim01 (Enabled)"`; click it → *Connect* button enabled. (2) fails. (3) Page: `_siteAreas` list loaded in `LoadInstancesForSite` (same try/catch), picker markup with `ItemLabel="@(i => $"{i.UniqueName} ({i.State})")"`, `SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="id => { _selectedInstanceId = id; OnInstanceSelectionChanged(); }"`, `Disabled="@_connected"`, `DataTestPrefix="debug-instance"`. (4) `DebugView*` tests pass. (5) Commit `feat(central-ui): Debug View picks the instance through InstancePicker`. + +--- + +### Task 6: Playwright tests drive the picker + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 4, Task 5 + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs` (~lines 209–213) +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs` (~68, ~93–97) +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs` (~79–83) + +Replace ` - - @foreach (var inst in _siteInstances) - { - - } - +
@if (!_connected) @@ -257,11 +253,22 @@ private List _sites = new(); private List _siteInstances = new(); + + // The site's areas, backing the InstancePicker's tree. Both lists are REPLACED + // (never mutated in place) on a site change — the picker compares them by + // reference and treats a new object as "the site changed". + private List _siteAreas = new(); private int _selectedSiteId; private int _selectedInstanceId; private bool _loading = true; private bool _connected; private bool _connecting; + + // True for the whole of LoadInstancesForSite. Blazor renders as soon as that load hits its + // first await, and the page is not _connected then — so without this the picker is enabled + // while it is still showing the PREVIOUS site's tree, and anything typed into it is wiped + // when the new lists land (the picker reads a new list object as "the site changed"). + private bool _instancesLoading; private bool _connectedFromStorage; // Which tab pane is visible — "attributes" (default) or "alarms". @@ -414,48 +421,93 @@ { _selectedSiteId = siteId; await LoadInstancesForSite(); + + // Membership guard, mirroring the ?instanceId= path above. A stored instance that + // has since been disabled (or deleted, or moved to another site) is not in the list + // the picker was handed, so it renders a blank field while the page holds a non-zero + // id and auto-connects to it anyway. Forget the stored instance instead — the site + // stays selected and loaded, so the operator just picks again. + var stored = _siteInstances.FirstOrDefault(i => i.Id == instanceId); + if (stored == null) + { + await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId"); + _toast.ShowInfo( + "The instance from your previous session is no longer available for debug streaming.", + autoDismissMs: 8000); + StateHasChanged(); + return; + } + _selectedInstanceId = instanceId; _connectedFromStorage = true; StateHasChanged(); await Connect(); // Auto-reconnect notice — the user didn't initiate this connection. - var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId); _toast.ShowInfo( - $"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.", + $"Auto-reconnected to {stored.UniqueName} from previous session.", autoDismissMs: 8000); } } + /// + /// Loads the selected site's instances and areas into the picker's two lists, holding + /// _instancesLoading for the whole round trip so the field cannot be typed into + /// while it is still showing the previous site's tree. The flag is cleared in a + /// finally: a scope refusal or a repository fault must not leave the picker + /// disabled forever. + /// private async Task LoadInstancesForSite() { - _siteInstances.Clear(); - _selectedInstanceId = 0; - if (_selectedSiteId == 0) return; - // Site scoping (CentralUI-002): re-check the claim server-side — a query - // string or stale localStorage value could name a site outside the grant. - if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId)) - { - _selectedSiteId = 0; - _toast.ShowError("You are not permitted to debug instances on that site."); - return; - } + _instancesLoading = true; try { - _siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) - .Where(i => i.State == InstanceState.Enabled) - .ToList(); + _siteInstances = new(); + _siteAreas = new(); + _selectedInstanceId = 0; + if (_selectedSiteId == 0) return; + // Site scoping (CentralUI-002): re-check the claim server-side — a query + // string or stale localStorage value could name a site outside the grant. + if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId)) + { + _selectedSiteId = 0; + _toast.ShowError("You are not permitted to debug instances on that site."); + return; + } + try + { + _siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) + .Where(i => i.State == InstanceState.Enabled) + .ToList(); + _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); + } + catch (Exception ex) + { + _toast.ShowError($"Failed to load instances: {ex.Message}"); + } } - catch (Exception ex) + finally { - _toast.ShowError($"Failed to load instances: {ex.Message}"); + _instancesLoading = false; } } - private void OnInstanceSelectionChanged() - { - // No-op; selection is tracked via _selectedInstanceId binding - } + /// + /// The label the InstancePicker renders on a row and in its field: + /// the instance's unique name with its lifecycle state. + /// + /// The instance to label. + /// The display label, e.g. Line1.Mixer (Enabled). + private static string InstanceLabel(Instance instance) => + $"{instance.UniqueName} ({instance.State})"; + + /// + /// Records the instance the operator picked in the InstancePicker. + /// Everything downstream (the Connect button's enablement, Connect() + /// itself) reads _selectedInstanceId, so there is nothing else to do. + /// + /// Id of the picked instance. + private void OnInstancePicked(int id) => _selectedInstanceId = id; /// /// When the attribute filter changes, expand the tree so any branches holding @@ -597,7 +649,8 @@ await Disconnect(); _selectedSiteId = 0; _selectedInstanceId = 0; - _siteInstances.Clear(); + _siteInstances = new(); + _siteAreas = new(); _toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000); } 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 67469b3c..1dfb1a66 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 @@ -59,16 +59,18 @@ }
-
+ @* Wider than the site select beside it: the field carries a whole instance + name, not a short site name. The script-entry branch still sums to 8 of 12 + (site 2 + instance 3 + script 3) alongside the auto-width toolbar columns. *@ +
- + @* A site can hold hundreds of instances, so this is the shared area-tree + combobox, not a flat list (design 2026-09-08). _areas/_instances are + loaded once per site — the picker treats a new list as a site change. *@ +
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 b61cf3d0..0c9608b9 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 @@ -111,7 +111,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable private List _methods = []; private List _sites = []; + + /// + /// The selected site's instances, feeding the InstancePicker alongside + /// . The picker compares both by REFERENCE — a new list object is read + /// as "the site changed" and closes its panel — so they are assigned exactly once per site + /// load () and never rebuilt on a render path. + /// private List _instances = []; + + /// The selected site's areas; the picker nests them into its tree. See . + private List _areas = []; + + /// + /// True while is fetching a site's instances and areas, and + /// the sole reason the picker is disabled for that window. Blazor renders the moment the load + /// hits its first await, and by then is already the NEW site + /// while the two lists are still the OLD ones — so without this the field is enabled over + /// stale data, and anything typed into it is wiped when the lists land (the picker reads a new + /// list object as "the site changed" and clears its text). + /// + private bool _instancesLoading; private List _scripts = []; private int _selectedMethodId; @@ -409,11 +429,43 @@ public partial class FlowDebugger : ComponentBase, IDisposable await PredictAsync(); } + /// + /// Loads the selected site's instances and areas into the picker's two lists, holding + /// for the whole round trip so the field cannot be typed + /// into while it is still showing the previous site's tree. The flag is cleared in a + /// finally: a repository fault must not leave the picker disabled forever. + /// private async Task LoadInstancesAsync() { - _instances = _selectedSiteId > 0 - ? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList() - : []; + _instancesLoading = true; + try + { + if (_selectedSiteId <= 0) + { + _instances = []; + _areas = []; + return; + } + + // Neither list is sorted here: the picker nests and orders both itself + // (InstanceTreeBuilder). The OrderBy this replaces dated from the flat + +
+ @if (_open) + { +
+
+ @* Selectable is what makes the tree's own Enter/Space activation reach us: it + routes BOTH a click on TreeView's content span and a key press on the + treeitem through SelectedKeyChanged, so mouse and keyboard share one path + (the row label below therefore carries no @onclick of its own — a second + handler there would fire twice on a click, since the click bubbles into the + content span). SelectedKey is deliberately left unbound: a combobox shows + its pick in the field, not as a row highlight, and binding it would also arm + TreeView's "selected key no longer in the tree" reset — which fires on every + render while the pick is filtered out, and this component would never clear + it. SelectedCssClass is emptied to match; with no SelectedKey nothing is ever + marked selected, so it is belt-and-braces. *@ + + + + @RowLabel(node) + + @(_hasFilter ? "No instances match." : "No instances at this site.") + +
+ } +
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs new file mode 100644 index 00000000..f9303110 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.cs @@ -0,0 +1,378 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// A combobox that picks one instance out of a site's area tree: a text field over a +/// dropdown panel hosting across the forest that +/// builds from the site's areas and instances. +/// +/// +/// It replaces the flat instance <select> on Flow Debugger and Debug View, +/// where a site with a few hundred instances made the list unusable. The panel opens +/// with every area collapsed so the operator drills down; an outside click closes it +/// through a transparent fixed backdrop (no JavaScript, the same pattern +/// uses for its context menu). +/// +/// +/// +/// Typing in the field filters the forest to the instances whose UniqueName contains +/// the text plus their ancestor areas, force-expanded so every match is on screen; clearing +/// the text restores the full forest, collapsed. Enter picks the first instance the +/// operator can actually see (document order, skipping collapsed branches), ArrowDown +/// hands keyboard navigation to the tree's own roving tabindex, and Escape closes +/// without changing the selection. The filter never survives a close or a pick. +/// +/// +/// +/// Inside the tree, Enter/Space on the focused row does exactly what a click on +/// it does — picks an instance, toggles an area — because the tree runs in +/// Selectable mode and both gestures arrive at +/// through TreeView's single selection callback. +/// +/// +/// +/// A parent that swaps / (a site change) +/// closes the panel and re-derives the field text from the current selection, clearing +/// it when the selected instance is not in the new list. A parent that changes +/// (a deep link or a localStorage restore) updates the +/// field text while the panel is closed. +/// +/// +public partial class InstancePicker +{ + /// + /// The site's areas, flat and linked by ; null is treated + /// as empty. Compared by REFERENCE: a new list object means "the site changed" and closes + /// the panel, drops the filter and re-derives the field text. Hold a stable reference and + /// only hand the picker a new list when the underlying set actually changed — rebuilding + /// the list on every parent render would close the panel out from under the operator. + /// + [Parameter] + public IReadOnlyList? Areas { get; set; } + + /// + /// The site's instances; null is treated as empty. Compared by REFERENCE on the same + /// contract as — pass a stable reference and only replace it when the + /// underlying set changed. + /// + [Parameter] + public IReadOnlyList? Instances { get; set; } + + /// Id of the currently selected instance; 0 means nothing is selected. + [Parameter] + public int SelectedInstanceId { get; set; } + + /// Raised with the instance id when the operator picks a row. + [Parameter] + public EventCallback SelectedInstanceIdChanged { get; set; } + + /// Disables the field and the chevron; the panel can no longer be opened. + [Parameter] + public bool Disabled { get; set; } + + /// + /// Maps an instance to the text shown on its row and, once picked, in the field. + /// Defaults to . Note that searching always matches + /// on UniqueName (the builder's contract), whatever this renders. + /// + [Parameter] + public Func? ItemLabel { get; set; } + + /// + /// data-test root for the control: {prefix}-picker, -input, + /// -toggle, -panel, -backdrop and -row (instance rows also + /// carry data-instance-id). + /// + [Parameter] + public string DataTestPrefix { get; set; } = "instance"; + + /// Placeholder shown in the field while nothing is selected. + [Parameter] + public string Placeholder { get; set; } = "Search or browse instances…"; + + /// + /// The combobox's accessible name, rendered as aria-label on the field. A page + /// that renders its own visible <label for> should point it at + /// ; the label wins for a screen reader either way, so this stays + /// as the fallback name rather than being suppressed. + /// + [Parameter] + public string AriaLabel { get; set; } = "Instance"; + + /// + /// DOM id for the text field, so a page can address it from a <label for>. + /// Unset means the per-instance generated id — two pickers on one page never collide. + /// + [Parameter] + public string? InputId { get; set; } + + private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8]; + + private bool _open; + private string _text = string.Empty; + + /// + /// The name filter applied to the forest. Empty until the search box lands; kept + /// separate from so a selected label never doubles as a filter. + /// + private string _filter = string.Empty; + + private bool _hasFilter; + private IReadOnlyList _roots = []; + private TreeView? _tree; + + /// Bulk expansion the tree owes the current filter, applied after the next render. + private enum PendingTreeAction + { + /// Nothing to do. + None, + + /// Open every branch so the filtered matches are all on screen. + ExpandAll, + + /// Close every branch, back to the drill-down starting point. + CollapseAll, + } + + // The TreeView only exists once the panel has rendered, so keyboard/typing work that + // targets it is deferred to OnAfterRenderAsync through these two flags. + private PendingTreeAction _pendingTreeAction; + private bool _pendingTreeFocus; + + // Last parameter values seen, so OnParametersSet can tell a real change from a + // parent that simply re-rendered with the same lists and selection. + private IReadOnlyList? _lastAreas; + private IReadOnlyList? _lastInstances; + private int _lastSelectedId; + private bool _parametersSeen; + + private string _inputId => InputId is { Length: > 0 } id ? id : $"ip-{_idSuffix}-input"; + + private string _panelId => $"ip-{_idSuffix}-panel"; + + /// + protected override void OnParametersSet() + { + var listsChanged = !_parametersSeen + || !ReferenceEquals(_lastAreas, Areas) + || !ReferenceEquals(_lastInstances, Instances); + var selectionChanged = !_parametersSeen || _lastSelectedId != SelectedInstanceId; + + _parametersSeen = true; + _lastAreas = Areas; + _lastInstances = Instances; + _lastSelectedId = SelectedInstanceId; + + if (!listsChanged && !selectionChanged) return; + + var selected = CurrentSelection(); + + if (listsChanged) + { + // A site change: drop any filter, rebuild, and close unconditionally — the panel + // is showing the OLD site's tree, so leaving it open would be showing a lie even + // when the selected instance happens to exist in the new list too. + _filter = string.Empty; + Rebuild(); + _open = false; + _text = selected is null ? string.Empty : LabelOf(selected); + } + else if (!_open) + { + _text = selected is null ? string.Empty : LabelOf(selected); + } + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var action = _pendingTreeAction; + var focus = _pendingTreeFocus; + _pendingTreeAction = PendingTreeAction.None; + _pendingTreeFocus = false; + + // The panel may have closed again in the same batch (a pick, an Escape), taking + // the tree with it — there is nothing left to expand or focus. + if (!_open || _tree is null) return; + + // ExpandAll/CollapseAll re-render the TREE, not this component, so consuming the + // flag here cannot loop back into another picker render. + switch (action) + { + case PendingTreeAction.ExpandAll: + _tree.ExpandAll(); + break; + case PendingTreeAction.CollapseAll: + _tree.CollapseAll(); + break; + } + + if (focus) await _tree.FocusFirstVisibleAsync(); + } + + /// The instance points at, or null when unresolved. + private Instance? CurrentSelection() => + SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId); + + /// Rebuilds the forest for the current filter. + private void Rebuild() + { + _hasFilter = !string.IsNullOrWhiteSpace(_filter); + _roots = InstanceTreeBuilder.Build(Areas, Instances, _filter); + } + + private string LabelOf(Instance instance) => ItemLabel?.Invoke(instance) ?? instance.UniqueName; + + private string RowLabel(InstanceTreeNode node) => + node.Instance is { } instance ? LabelOf(instance) : node.Label; + + private void Open() + { + if (Disabled || _open) return; + _open = true; + } + + private void Toggle() + { + if (Disabled) return; + if (_open) Close(); else _open = true; + } + + private void Close() + { + _open = false; + if (_hasFilter) + { + _filter = string.Empty; + Rebuild(); + } + + var selected = CurrentSelection(); + _text = selected is null ? string.Empty : LabelOf(selected); + } + + /// + /// Every keystroke retypes the filter: the field text IS the filter while the operator + /// is typing, and the panel opens if it was closed. The rebuilt forest is expanded when + /// something is being searched for and collapsed back to the drill-down view when the + /// text is cleared — both after the next render, once the tree exists. + /// + private void OnInput(ChangeEventArgs e) + { + if (Disabled) return; + + _text = e.Value?.ToString() ?? string.Empty; + _filter = _text; + _open = true; + Rebuild(); + _pendingTreeAction = _hasFilter ? PendingTreeAction.ExpandAll : PendingTreeAction.CollapseAll; + } + + /// + /// The field's keyboard surface. Deliberately no preventDefault: Blazor's flag is + /// all-or-nothing per element, and neither default is harmful here (Enter in a text input + /// outside a form does nothing; ArrowDown just moves the caret) — while suppressing Tab + /// would break getting out of the control. + /// + private Task OnInputKeyDown(KeyboardEventArgs e) + { + if (Disabled) return Task.CompletedTask; + + switch (e.Key) + { + case "Enter" when _open: + return FirstVisibleInstance() is { } instance ? SelectAsync(instance) : Task.CompletedTask; + + case "Escape" when _open: + Close(); + break; + + case "ArrowDown": + _open = true; + _pendingTreeFocus = true; + break; + } + + return Task.CompletedTask; + } + + /// + /// The first instance leaf in document order among the rows the operator can actually + /// see — a collapsed area's contents are skipped, matching what Enter appears to pick. + /// With a filter active every branch is expanded, so this degrades to plain document + /// order over the filtered forest. + /// + /// + /// Reading is safe here because Blazor Server + /// dispatches every UI event on one renderer sync context: the render and the pending + /// ExpandAll/CollapseAll that applies both + /// complete before the NEXT keydown is dispatched, so the tree's expansion state can never + /// be one keystroke stale. That would stop holding if the pending action ever became + /// genuinely async (an await that yields inside ); it would + /// then have to be awaited before Enter is allowed to read the tree. + /// + /// + private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots); + + private Instance? FirstVisibleInstance(IReadOnlyList nodes) + { + foreach (var node in nodes) + { + if (node.Instance is { } instance) return instance; + if (!node.HasChildren) continue; + + // Before the tree's first render the filter's pending ExpandAll decides visibility. + var expanded = _tree?.IsExpanded(node.Key) ?? _hasFilter; + if (expanded && FirstVisibleInstance(node.Children) is { } hit) return hit; + } + + return null; + } + + /// + /// The single activation path for a tree row, reached from TreeView's + /// SelectedKeyChanged — which a mouse click on the row and an + /// Enter/Space on the focused treeitem both run through, so the two input + /// modes can never diverge. An instance key picks that instance; an area key toggles the + /// branch (there is nothing to select on an area, and a keyboard user who cannot open a + /// branch cannot reach the instances inside it). + /// + /// + /// Keys are the ones mints: i:{instanceId} and + /// a:{areaId}. Anything else — including the null TreeView raises when it clears a + /// selection — is ignored rather than guessed at. + /// + /// + private Task OnTreeKeyActivatedAsync(object? key) + { + if (Disabled || key?.ToString() is not { Length: > 0 } keyStr) return Task.CompletedTask; + + if (keyStr.StartsWith("i:", StringComparison.Ordinal)) + { + return int.TryParse(keyStr.AsSpan(2), out var instanceId) + && Instances?.FirstOrDefault(i => i.Id == instanceId) is { } instance + ? SelectAsync(instance) + : Task.CompletedTask; + } + + if (keyStr.StartsWith("a:", StringComparison.Ordinal)) _tree?.ToggleExpanded(keyStr); + + return Task.CompletedTask; + } + + private async Task SelectAsync(Instance instance) + { + _text = LabelOf(instance); + _open = false; + if (_hasFilter) + { + _filter = string.Empty; + Rebuild(); + } + + await SelectedInstanceIdChanged.InvokeAsync(instance.Id); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css new file mode 100644 index 00000000..abc9d954 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstancePicker.razor.css @@ -0,0 +1,47 @@ +/* Instance Picker combobox — the field, the dropdown panel and the outside-click + backdrop are stacked deliberately: backdrop (1040) < panel (1041) < field (1042). */ + +/* The backdrop is a full-viewport fixed layer, so an UNPOSITIONED input-group would be + painted underneath it and every click on the field or the chevron would be swallowed + by the backdrop (= panel closes, cannot keep typing). Giving the field its own + positioned layer above both keeps it clickable while the panel is open; the panel + sits at top:100% so being one level lower never hides it. */ +.ip-field { + position: relative; + z-index: 1042; +} + +/* Transparent full-viewport catcher: a click anywhere outside the panel closes it. + Sits just under the panel and above page content (Bootstrap's dropdown z-index + band), mirroring TreeView's context-menu overlay. */ +.ip-backdrop { + position: fixed; + inset: 0; + z-index: 1040; + background: transparent; +} + +/* Dropdown panel anchored under the field (the wrapper is position-relative). + Scrolls once the site's tree outgrows 40% of the viewport. + + INTEGRATORS: the panel is absolutely positioned INSIDE the picker's wrapper, so any + ancestor with `overflow: hidden`/`auto` (a card body, a scrolling toolbar, a table cell) + clips it — the tree appears truncated or invisible while the field still says it is open. + Place the picker outside such a container, or let that container overflow visibly. */ +.ip-panel { + position: absolute; + left: 0; + right: 0; + top: 100%; + z-index: 1041; + max-height: 40vh; + overflow-y: auto; + min-width: 18rem; + padding: 0.25rem; +} + +/* Both row kinds are clickable: an area toggles, an instance selects. */ +.ip-instance, +.ip-area { + cursor: pointer; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs new file mode 100644 index 00000000..5f9ff9d1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs @@ -0,0 +1,86 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// Builds the Instance Picker's area/instance forest from a site's flat area and +/// instance lists. Pure — no Blazor, no DI, no I/O — so the shape is unit-testable +/// on its own and the picker component only has to render it. +/// +public static class InstanceTreeBuilder +{ + /// + /// Nests the supplied areas by and hangs each area's + /// instances beneath it, mirroring the Topology page's ordering: at every level the + /// child areas come first (sorted by name, ordinal-ignore-case), then that level's + /// instances (sorted by unique name, ordinal-ignore-case). Instances with no area sit + /// at the root after the root areas. A row whose parent points at an area that is not in + /// is treated as a root — both an orphaned area and an instance + /// with an unknown area id — so a stale row can never hide a subtree or an instance. + /// + /// The site's areas (flat, linked by ); null is treated as empty. + /// The site's instances; null is treated as empty. + /// + /// Optional case-insensitive substring matched against . + /// When set, only matching instances are kept along with their ancestor areas; areas left + /// with nothing beneath them are dropped. Null or whitespace means no filter. + /// + /// The root rows of the forest, never null. + public static IReadOnlyList Build( + IReadOnlyList? areas, + IReadOnlyList? instances, + string? filter) + { + areas ??= []; + instances ??= []; + + var needle = filter?.Trim(); + var hasFilter = !string.IsNullOrEmpty(needle); + + var knownAreaIds = areas.Select(a => a.Id).ToHashSet(); + + // Child areas by effective parent id — null for a root or an orphaned area. + var areasByParent = areas + .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .ToLookup(a => a.ParentAreaId is int p && knownAreaIds.Contains(p) ? p : (int?)null); + + var instancesByArea = instances + .Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase) + .ToLookup(i => i.AreaId is int a && knownAreaIds.Contains(a) ? a : (int?)null); + + return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []); + } + + private static List BuildLevel( + int? parentId, + ILookup areasByParent, + ILookup instancesByArea, + bool hasFilter, + HashSet ancestors) + { + var level = new List(); + + foreach (var area in areasByParent[parentId]) + { + // Defensive: a cycle in ParentAreaId must never recurse forever. + if (!ancestors.Add(area.Id)) continue; + + var node = new InstanceTreeNode($"a:{area.Id}", area.Name, area, Instance: null); + foreach (var child in BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors)) + { + node.AddChild(child); + } + + ancestors.Remove(area.Id); + + // With a filter an area survives only if something matched beneath it. + if (!hasFilter || node.HasChildren) level.Add(node); + } + + level.AddRange(instancesByArea[parentId].Select(i => + new InstanceTreeNode($"i:{i.Id}", i.UniqueName, Area: null, Instance: i))); + + return level; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs new file mode 100644 index 00000000..91d02518 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs @@ -0,0 +1,30 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// One row of the Instance Picker tree: an area branch or an instance leaf. +/// Keys are a:{areaId} for areas and i:{instanceId} for instances. +/// Children are populated by and read-only to callers. +/// +/// Stable tree key — a:{areaId} or i:{instanceId}. +/// Text rendered for this row. +/// The area this node represents, or null for an instance leaf. +/// The instance this node represents, or null for an area branch. +public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance) +{ + private readonly List _children = new(); + + /// Gets the child rows nested under this node (child areas first, then instances). + public IReadOnlyList Children => _children; + + /// Gets a value indicating whether this node is a selectable instance leaf. + public bool IsInstance => Instance is not null; + + /// Gets a value indicating whether this node has any child rows. + public bool HasChildren => _children.Count > 0; + + /// Appends a child row. Only the builder composes the tree. + /// The child row to append. + internal void AddChild(InstanceTreeNode child) => _children.Add(child); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor index e5f52320..72c854ba 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor @@ -601,6 +601,35 @@ else /// Whether the node with the given key is currently expanded. public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key)); + /// + /// Make the first visible node the roving-tabindex target and pull browser focus to it, + /// so a host control above the tree (a combobox field handing over on ArrowDown) can + /// enter the tree's own arrow navigation. Focus is applied by the existing + /// _focusNeedsApply path after the re-render this schedules. No-op on an empty tree. + /// + /// A completed task once the focus request has been scheduled. + public Task FocusFirstVisibleAsync() + { + var visible = BuildVisibleNodes(); + if (visible.Count == 0) return Task.CompletedTask; + + MoveFocusTo(visible[0].Key); + StateHasChanged(); + return Task.CompletedTask; + } + + /// + /// Expand the node with the given key if it is collapsed, collapse it otherwise — + /// the same toggle the row chevron performs, for a consumer whose own + /// NodeContent handles the click (a label that toggles its branch). + /// + /// Key of the node to toggle, as produced by KeySelector. + public void ToggleExpanded(object key) + { + ToggleExpand(key); + StateHasChanged(); + } + /// Expand every branch node in the tree. public void ExpandAll() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs index a27674f4..c2808fb1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs @@ -12,15 +12,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; /// without hanging. /// /// -/// The Enabled-only dropdown + deploy precondition (verified against -/// DebugView.razor::LoadInstancesForSite): the instance <select> -/// lists ONLY instances whose config state is Enabled +/// The Enabled-only instance picker + deploy precondition (verified against +/// DebugView.razor::LoadInstancesForSite): the instance +/// InstancePicker combobox (prefix debug-instance) lists ONLY +/// instances whose config state is Enabled /// (i.State == InstanceState.Enabled). A freshly-minted instance is /// NotDeployed and would never appear, so Fact B first drives the instance /// to Enabled over the CLI (instance deploy) before it can be picked -/// in the dropdown. Both <select>s carry the data-test hooks -/// (debug-site-select / debug-instance-select, option value = entity -/// id); Fact A asserts those hooks are served, which doubles as proof the cluster +/// in the picker. The site <select> carries the data-test hook +/// debug-site-select (option value = entity id); the instance picker carries +/// the shared InstancePicker hooks (debug-instance-picker/-input/ +/// -toggle/-panel/-row, row data-instance-id = entity id). +/// Fact A asserts those hooks are served, which doubles as proof the cluster /// rebuild that added them actually took. /// /// @@ -65,7 +68,7 @@ public class DebugViewTests : IClassFixture await Assertions.Expect(page.Locator("h4:has-text('Debug View')")).ToBeVisibleAsync(); // The data-test hooks from Task 4 must be served (proves the rebuild took). await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToHaveCountAsync(1); - await Assertions.Expect(page.Locator("[data-test='debug-instance-select']")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator("[data-test='debug-instance-picker']")).ToHaveCountAsync(1); // No site/instance selected -> Connect is disabled. var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')"); @@ -90,11 +93,11 @@ public class DebugViewTests : IClassFixture await page.Locator("[data-test='debug-site-select']") .SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() }); - var instanceOption = page.Locator("[data-test='debug-instance-select'] option", - new() { HasText = uniqueName }); - await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 }); - await page.Locator("[data-test='debug-instance-select']") - .SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() }); + await page.Locator("[data-test='debug-instance-input']").FillAsync(uniqueName); + var instanceRow = page.Locator( + $"[data-test='debug-instance-row'][data-instance-id='{instanceId}']"); + await Assertions.Expect(instanceRow).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await instanceRow.ClickAsync(); var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')"); await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 }); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs index 189ca20d..82953cda 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs @@ -76,11 +76,11 @@ public class DebugViewTreeTests : IClassFixture // Select the seeded site + Enabled instance (same hooks as DebugViewTests). await page.Locator("[data-test='debug-site-select']") .SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() }); - var instanceOption = page.Locator("[data-test='debug-instance-select'] option", - new() { HasText = uniqueName }); - await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 }); - await page.Locator("[data-test='debug-instance-select']") - .SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() }); + await page.Locator("[data-test='debug-instance-input']").FillAsync(uniqueName); + var instanceRow = page.Locator( + $"[data-test='debug-instance-row'][data-instance-id='{instanceId}']"); + await Assertions.Expect(instanceRow).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await instanceRow.ClickAsync(); var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')"); await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 }); 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 c48ea0c1..0a00613c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/FlowDebuggerTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.RegularExpressions; using Microsoft.Playwright; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; using Xunit; @@ -193,7 +194,9 @@ public class FlowDebuggerTests : IClassFixture /// rig's , skipping the test with a rig-shaped message whenever an /// option is missing — none of these objects is provisioned by the fixture. Site is matched by /// LABEL because its id differs per rig (the seed names it "Test Plant A", identifier - /// site-a); the script is matched by option VALUE, which is its canonical name. + /// site-a); the instance goes through the shared InstancePicker combobox (typed + /// name filters the tree, exact-text row click selects); the script is matched by option VALUE, + /// which is its canonical name. /// private static async Task SelectRigScriptAsync(IPage page, string scriptName) { @@ -206,11 +209,20 @@ public class FlowDebuggerTests : IClassFixture Skip.If(siteLabel is null, "The rig's site-a ('Test Plant A') is not in the Flow Debugger site list — run docker/seed-sites.sh."); await sites.SelectOptionAsync(new SelectOptionValue { Label = siteLabel! }); - var instances = page.Locator("select[data-test='flow-instance-select']"); - var instanceLabel = await WaitForOptionLabelAsync( - page, instances, l => string.Equals(l, RigInstanceName, StringComparison.Ordinal)); - Skip.If(instanceLabel is null, $"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh."); - await instances.SelectOptionAsync(new SelectOptionValue { Label = instanceLabel! }); + // Instance selection goes through the shared InstancePicker combobox (prefix + // "flow-instance"): typing the name filters the area tree and expands its + // ancestors, then the exact-text row is clicked to select and close the panel. The + // test only knows the instance NAME (not its id), so the row is matched through the + // data-test contract (flow-instance-row) with an exact-text regex rather than a + // free-text GetByText scan. + var instanceInput = page.Locator("[data-test='flow-instance-input']"); + await instanceInput.FillAsync(RigInstanceName); + var instanceRow = page.Locator("[data-test='flow-instance-row']", + new() { HasTextRegex = new Regex($"^\\s*{Regex.Escape(RigInstanceName)}\\s*$") }); + Skip.IfNot( + await WaitForCountAsync(page, instanceRow), + $"Instance {RigInstanceName} is not on the rig's site-a — run docker/seed-delmia-sim.sh."); + await instanceRow.ClickAsync(); var scripts = page.Locator("select[data-test='flow-script-select']"); var option = scripts.Locator($"option[value='{scriptName}']"); @@ -223,8 +235,8 @@ public class FlowDebuggerTests : IClassFixture /// /// Polls a <select>'s option labels until one matches, returning it (or null on - /// timeout). The instance and script lists are filled by a server round-trip after the previous - /// picker changes, so the options do not exist at first render. + /// timeout). Used for the site select, whose options are filled by a server round-trip after + /// the page loads, so the options do not exist at first render. /// private static async Task WaitForOptionLabelAsync( IPage page, ILocator select, Func match, int timeoutMs = 15_000) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs new file mode 100644 index 00000000..b7273e81 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs @@ -0,0 +1,231 @@ +using System.Reflection; +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; +using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// bUnit tests for the Debug View's instance selector after it moved from a flat +/// <select> to the shared InstancePicker area-tree combobox +/// (design 2026-09-08). Pins that the page hands the picker the site's areas and +/// its Enabled-only instance list, that drilling down the tree selects an instance, +/// that the selection still enables Connect, and that a live connection +/// locks the picker down. +/// +public class DebugViewInstancePickerTests : BunitContext +{ + private const int SiteId = 1; + + private readonly ITemplateEngineRepository _repo = Substitute.For(); + + private void RegisterServices() + { + // DebugView touches localStorage on render; let bUnit answer loosely. + JSInterop.Mode = JSRuntimeMode.Loose; + + _repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List + { + new("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }, + new("Off01") { Id = 8, SiteId = SiteId, State = InstanceState.Disabled }, + }); + _repo.GetAreasBySiteIdAsync(SiteId).Returns(new List + { + new("Line1") { Id = 1, SiteId = SiteId }, + new("Cell A") { Id = 2, SiteId = SiteId, ParentAreaId = 1 }, + }); + Services.AddSingleton(_repo); + + var siteRepo = Substitute.For(); + siteRepo.GetAllSitesAsync().Returns(new List + { + new("Site One", "site-a") { Id = SiteId }, + }); + Services.AddSingleton(siteRepo); + + var comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + Services.AddSingleton(comms); + + var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance); + // An empty throwaway provider — these tests never call StartStreamAsync, + // so the provider is unused. (Services.BuildServiceProvider() would leak + // an undisposed provider.) + var debugStream = new DebugStreamService( + comms, new ServiceCollection().BuildServiceProvider(), grpcFactory, + NullLogger.Instance); + Services.AddSingleton(debugStream); + + // No SiteId claim => system-wide, so site 1 passes the scope re-check. + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); + var authState = new AuthenticationState(new ClaimsPrincipal(identity)); + var stubAuth = new StubAuthStateProvider(authState); + Services.AddSingleton(stubAuth); + Services.AddScoped(_ => new SiteScopeService(stubAuth)); + } + + private sealed class StubAuthStateProvider : AuthenticationStateProvider + { + private readonly AuthenticationState _state; + public StubAuthStateProvider(AuthenticationState state) => _state = state; + public override Task GetAuthenticationStateAsync() + => Task.FromResult(_state); + } + + /// Renders the page, selects the seeded site and opens the picker panel. + private IRenderedComponent RenderWithOpenPicker() + { + RegisterServices(); + var cut = Render(); + cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString()); + cut.Find("[data-test='debug-instance-toggle']").Click(); + return cut; + } + + private static void ClickRow(IRenderedComponent cut, string text) => + cut.FindAll("[data-test='debug-instance-row']") + .First(r => r.TextContent.Trim() == text) + .Click(); + + [Fact] + public void InstanceSelector_IsThePicker_NotAFlatSelect() + { + RegisterServices(); + var cut = Render(); + + Assert.Single(cut.FindAll("[data-test='debug-instance-picker']")); + Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']")); + } + + [Fact] + public void OpeningThePicker_ShowsOnlyTheRootArea() + { + var cut = RenderWithOpenPicker(); + + var rows = cut.FindAll("[data-test='debug-instance-row']"); + Assert.Single(rows); + Assert.Equal("Line1", rows[0].TextContent.Trim()); + Assert.False(rows[0].HasAttribute("data-instance-id")); + } + + [Fact] + public void DrillingDown_RevealsTheEnabledInstanceOnly() + { + var cut = RenderWithOpenPicker(); + + ClickRow(cut, "Line1"); + ClickRow(cut, "Cell A"); + + var instanceRow = cut.Find("[data-instance-id='7']"); + Assert.Equal("Sim01 (Enabled)", instanceRow.TextContent.Trim()); + + // Off01 is Disabled, so the page filters it out before the picker sees it. + Assert.DoesNotContain( + cut.FindAll("[data-test='debug-instance-row']"), + r => r.TextContent.Contains("Off01")); + } + + [Fact] + public void PickingAnInstance_FillsTheField_AndEnablesConnect() + { + var cut = RenderWithOpenPicker(); + + ClickRow(cut, "Line1"); + ClickRow(cut, "Cell A"); + cut.Find("[data-instance-id='7']").Click(); + + Assert.Equal( + "Sim01 (Enabled)", + cut.Find("[data-test='debug-instance-input']").GetAttribute("value")); + + var connect = cut.FindAll("button.btn.btn-primary.btn-sm") + .First(b => b.TextContent.Contains("Connect")); + Assert.False(connect.HasAttribute("disabled")); + } + + /// + /// The picker must stay disabled for the WHOLE site load, not just until the site id lands. + /// Blazor renders as soon as LoadInstancesForSite hits its first await, and at that point + /// the page is not _connected — so the field was already enabled while the site's lists + /// were still loading. Typing into it then (an operator, or Playwright's FillAsync, which + /// waits only for "enabled") lost the text: the picker reads the freshly-assigned lists as + /// "the site changed" and clears the field and closes the panel. + /// + [Fact] + public void PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded() + { + RegisterServices(); + // Hold the instance load open so the render that follows the first await is observable. + var gate = new TaskCompletionSource>(); + _repo.GetInstancesBySiteIdAsync(SiteId).Returns(gate.Task); + + var cut = Render(); + cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString()); + + Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")); + Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled")); + + cut.InvokeAsync(() => gate.SetResult( + [new Instance("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }])); + + cut.WaitForAssertion(() => + Assert.False(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"))); + } + + /// + /// The localStorage auto-reconnect must apply the same membership guard the + /// ?instanceId= deep link does. Off01 is Disabled, so the page filters it out of the + /// list the picker is handed — restoring it anyway left the field blank while the page held + /// a non-zero id and auto-connected to it. The stored id is dropped instead, and the site + /// stays selected and loaded so the operator can pick again. + /// + [Fact] + public void AutoReconnect_FromStorage_SkipsAnInstanceThatIsNoLongerAvailable() + { + RegisterServices(); + JSInterop.Setup("localStorage.getItem", "debugView.siteId").SetResult(SiteId.ToString()); + JSInterop.Setup("localStorage.getItem", "debugView.instanceId").SetResult("8"); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Equal(string.Empty, cut.Find("[data-test='debug-instance-input']").GetAttribute("value"))); + + // Nothing is selected, so Connect stays disabled — and the stale id is forgotten. + var connect = cut.FindAll("button.btn.btn-primary.btn-sm").First(b => b.TextContent.Contains("Connect")); + Assert.True(connect.HasAttribute("disabled")); + JSInterop.VerifyInvoke("localStorage.removeItem"); + } + + [Fact] + public void WhileConnected_ThePickerIsDisabled() + { + RegisterServices(); + var cut = Render(); + cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString()); + + // Mark the page connected the way the other DebugView page tests do — + // driving the real gRPC stream is not available to a bUnit render. + cut.InvokeAsync(() => typeof(DebugViewPage) + .GetField("_connected", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(cut.Instance, true)); + cut.Render(); + + Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")); + Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled")); + } +} 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 ccc22fea..6bde8719 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -603,10 +603,27 @@ public class FlowDebuggerPageTests : BunitContext """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", 20); private static readonly FlowScriptOption Tick = new("Tick", "Interval", null, null, null); + /// + /// Picks an instance through the InstancePicker combobox that replaced the flat + /// instance <select> (design 2026-09-08): open the panel, click the row. The + /// fixture instance has no area, so its row sits at the root and needs no expansion. + /// + private static void PickInstance(IRenderedComponent cut, int id) + { + cut.Find("[data-test='flow-instance-toggle']").Click(); + cut.Find($"[data-test='flow-instance-row'][data-instance-id='{id}']").Click(); + } + + /// Clicks the picker row (an area label toggles it, an instance label selects it). + private static void ClickPickerRow(IRenderedComponent cut, string label) => + cut.FindAll("[data-test='flow-instance-row']").First(r => r.TextContent.Trim() == label).Click(); + private void WireInstance() { _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) .Returns(Task.FromResult>([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); + _templates.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>([])); _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }); _flow.GetInstanceScriptsAsync(7, Arg.Any()).Returns(Task.FromResult>([Manual, OnFlag, Tick])); _flow.PredictScriptAsync(7, Arg.Any(), Arg.Any()).Returns(ThreeNodePrediction()); @@ -620,7 +637,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']")); @@ -634,7 +651,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("OnFlag"); Assert.Contains("Flag", cut.Find("[data-test='flow-inputs']").TextContent); @@ -653,7 +670,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("Manual"); Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']")); @@ -672,7 +689,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("Tick"); Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); @@ -714,7 +731,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = Render(); cut.Find("button[data-test='flow-entry-script']").Click(); cut.Find("select[data-test='flow-site-select']").Change("1"); - cut.Find("select[data-test='flow-instance-select']").Change("7"); + PickInstance(cut, 7); cut.Find("select[data-test='flow-script-select']").Change("Manual"); cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); @@ -751,4 +768,139 @@ public class FlowDebuggerPageTests : BunitContext Assert.Contains("takes no parameters", cut.Find("[data-test='flow-inputs']").TextContent); Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); } + + [Fact] + public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded() + { + RegisterServices(); + WireInstance(); + // Same instance, now filed under Line1 / Cell A so the picker has a tree to drill into. + _templates.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>( + [new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }])); + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>( + [new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }])); + + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + cut.Find("[data-test='flow-instance-toggle']").Click(); + + // The panel opens collapsed: only the root area, and the instance is not rendered at all. + Assert.Equal(["Line1"], cut.FindAll("[data-test='flow-instance-row']").Select(r => r.TextContent.Trim()).ToList()); + Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']")); + + ClickPickerRow(cut, "Line1"); + Assert.Empty(cut.FindAll("[data-test='flow-instance-row'][data-instance-id='7']")); + + ClickPickerRow(cut, "Cell A"); + cut.Find("[data-test='flow-instance-row'][data-instance-id='7']").Click(); + + // Picking the row ran OnInstanceChangedAsync, so the script list is loaded. + var options = cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList(); + Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options); + } + + [Fact] + public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker() + { + RegisterServices(); + WireInstance(); + var nav = Services.GetRequiredService(); + nav.NavigateTo("/deployment/flow-debugger?instanceId=7"); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value"))); + } + + /// + /// The same deep link, but the instance sits two areas deep. The picker resolves the field + /// text from SelectedInstanceId against the flat instance list, not by walking the + /// tree — so a nested instance must show its name with the panel never opened (and therefore + /// with Line1 / Cell A never expanded). + /// + [Fact] + public void ScriptEntry_DeepLink_InstanceIdInsideAnArea_ShowsItsNameWithoutOpeningThePanel() + { + RegisterServices(); + WireInstance(); + _templates.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>( + [new Area("Line1") { Id = 1, SiteId = 1 }, new Area("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }])); + var nested = new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10, AreaId = 2 }; + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()) + .Returns(Task.FromResult>([nested])); + _templates.GetInstanceByIdAsync(7, Arg.Any()).Returns(nested); + + var nav = Services.GetRequiredService(); + nav.NavigateTo("/deployment/flow-debugger?instanceId=7"); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value"))); + // The panel was never opened, so nothing of the tree is rendered. + Assert.Empty(cut.FindAll("[data-test='flow-instance-panel']")); + Assert.Empty(cut.FindAll("[data-test='flow-instance-row']")); + } + + /// + /// The picker must stay disabled for the WHOLE site load, not just until the site id lands. + /// Blazor renders as soon as OnSiteChangedAsync hits its first await, and at that point + /// _selectedSiteId is already non-zero while _instances/_areas are still the + /// previous site's lists — so an operator (or Playwright's FillAsync, which waits only for + /// "enabled") could type into the field and have the load wipe the text and close the panel a + /// moment later, because the picker reads a new list object as "the site changed". + /// + [Fact] + public void ScriptEntry_PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded() + { + RegisterServices(); + WireInstance(); + // Hold the instance load open so the render that follows the first await is observable. + var gate = new TaskCompletionSource>(); + _templates.GetInstancesBySiteIdAsync(1, Arg.Any()).Returns(gate.Task); + + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + + Assert.True(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")); + Assert.True(cut.Find("[data-test='flow-instance-toggle']").HasAttribute("disabled")); + + cut.InvokeAsync(() => gate.SetResult([new Instance("Sim01") { Id = 7, SiteId = 1, TemplateId = 10 }])); + + cut.WaitForAssertion(() => + Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled"))); + } + + [Fact] + public void ScriptEntry_SiteChange_ClearsThePickedInstance() + { + RegisterServices(); + WireInstance(); + // A second site the operator can switch to, with no instances of its own. + _sites.GetAllSitesAsync(Arg.Any()).Returns(Task.FromResult>( + [new Site("Plant A", "site-a") { Id = 1 }, new Site("Plant B", "site-b") { Id = 2 }])); + _templates.GetInstancesBySiteIdAsync(2, Arg.Any()) + .Returns(Task.FromResult>([])); + _templates.GetAreasBySiteIdAsync(2, Arg.Any()) + .Returns(Task.FromResult>([])); + + var cut = Render(); + cut.Find("button[data-test='flow-entry-script']").Click(); + cut.Find("select[data-test='flow-site-select']").Change("1"); + PickInstance(cut, 7); + Assert.Equal("Sim01", cut.Find("[data-test='flow-instance-input']").GetAttribute("value")); + + cut.Find("select[data-test='flow-site-select']").Change("2"); + + // The pick is dropped on both sides: the page's id and the picker's field text, which + // it re-derives because the new site handed it a different instance list. + Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value")); + Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList()); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs new file mode 100644 index 00000000..d67a77df --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstancePickerTests.cs @@ -0,0 +1,559 @@ +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// +/// bUnit tests for the combobox: the closed field, the +/// panel that opens with every area collapsed, area expansion, instance selection +/// (callback + close + field text), the disabled guard, the outside-click backdrop, +/// parent-driven selection changes, the ItemLabel hook, and the search filter +/// with its keyboard surface (Enter / Escape / ArrowDown). +/// +public class InstancePickerTests : BunitContext +{ + public InstancePickerTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private static List Areas() => + [ + new("Line1") { Id = 1, SiteId = 1 }, + new("Cell A") { Id = 2, SiteId = 1, ParentAreaId = 1 }, + ]; + + private static List Instances() => + [ + new("P1") { Id = 7, SiteId = 1, AreaId = 2 }, + new("Zulu") { Id = 8, SiteId = 1 }, + ]; + + private IRenderedComponent RenderPicker( + Action? onChanged = null, + bool disabled = false, + Func? itemLabel = null, + int selectedInstanceId = 0) => + Render(ps => + { + ps.Add(p => p.Areas, Areas()); + ps.Add(p => p.Instances, Instances()); + ps.Add(p => p.DataTestPrefix, "t"); + ps.Add(p => p.Disabled, disabled); + ps.Add(p => p.SelectedInstanceId, selectedInstanceId); + if (itemLabel is not null) ps.Add(p => p.ItemLabel, itemLabel); + if (onChanged is not null) ps.Add(p => p.SelectedInstanceIdChanged, onChanged); + }); + + private static void Open(IRenderedComponent cut) => + cut.Find("[data-test='t-toggle']").Click(); + + private static void ClickRow(IRenderedComponent cut, string text) => + cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == text).Click(); + + private static string InputValue(IRenderedComponent cut) => + cut.Find("[data-test='t-input']").GetAttribute("value") ?? string.Empty; + + private static void Type(IRenderedComponent cut, string text) => + cut.Find("[data-test='t-input']").Input(text); + + private static void Press(IRenderedComponent cut, string key) => + cut.Find("[data-test='t-input']").KeyDown(new KeyboardEventArgs { Key = key }); + + private static string[] Rows(IRenderedComponent cut) => + cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray(); + + /// The nth treeitem in document order - i.e. the nth row the operator can see. + private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent cut, int index) => + cut.FindAll("[role='treeitem']")[index]; + + [Fact] + public void Closed_ShowsNothingButTheField() + { + var cut = RenderPicker(); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + + var input = cut.Find("[data-test='t-input']"); + Assert.Equal("combobox", input.GetAttribute("role")); + Assert.Equal("false", input.GetAttribute("aria-expanded")); + } + + [Fact] + public void Toggle_OpensPanel_WithAreasCollapsed() + { + var cut = RenderPicker(); + + Open(cut); + + Assert.Single(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("true", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded")); + + var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray(); + Assert.Equal(new[] { "Line1", "Zulu" }, rows); + + // P1 sits under a collapsed area, so its row is not rendered at all. + Assert.Empty(cut.FindAll("[data-instance-id='7']")); + + // Area rows must not carry a data-instance-id at all (Blazor drops a null attribute). + var areaRow = cut.FindAll("[data-test='t-row']").First(r => r.TextContent.Trim() == "Line1"); + Assert.False(areaRow.HasAttribute("data-instance-id")); + } + + [Fact] + public void ClickingAnAreaLabel_ExpandsIt() + { + var cut = RenderPicker(); + Open(cut); + + ClickRow(cut, "Line1"); + Assert.Contains(cut.FindAll("[data-test='t-row']"), r => r.TextContent.Trim() == "Cell A"); + Assert.Empty(cut.FindAll("[data-instance-id='7']")); + + ClickRow(cut, "Cell A"); + Assert.Single(cut.FindAll("[data-instance-id='7']")); + } + + [Fact] + public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + + Open(cut); + ClickRow(cut, "Line1"); + ClickRow(cut, "Cell A"); + cut.Find("[data-instance-id='7']").Click(); + + Assert.Equal(7, picked); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("P1", InputValue(cut)); + } + + [Fact] + public void Disabled_NeverOpens() + { + var cut = RenderPicker(disabled: true); + + Assert.True(cut.Find("[data-test='t-input']").HasAttribute("disabled")); + Assert.True(cut.Find("[data-test='t-toggle']").HasAttribute("disabled")); + + Open(cut); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + } + + [Fact] + public void Backdrop_ClickClosesThePanel_WithoutChangingSelection() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + + Open(cut); + cut.Find("[data-test='t-backdrop']").Click(); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Null(picked); + Assert.Equal(string.Empty, InputValue(cut)); + } + + [Fact] + public void ParentDrivenSelection_UpdatesTheFieldText() + { + var cut = RenderPicker(); + + cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 8)); + Assert.Equal("Zulu", InputValue(cut)); + + cut.Render(ps => ps.Add(p => p.SelectedInstanceId, 0)); + Assert.Equal(string.Empty, InputValue(cut)); + } + + [Fact] + public void SiteChange_NewInstancesList_ClearsAStaleSelectionText() + { + var cut = RenderPicker(selectedInstanceId: 8); + Assert.Equal("Zulu", InputValue(cut)); + + Open(cut); + + // A different site: neither the areas nor the previously selected instance survive. + cut.Render(ps => + { + ps.Add(p => p.Areas, new List()); + ps.Add(p => p.Instances, new List { new("Other") { Id = 99, SiteId = 2 } }); + }); + + Assert.Equal(string.Empty, InputValue(cut)); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + } + + [Fact] + public void ItemLabel_IsUsedForRowsAndField() + { + var cut = RenderPicker(itemLabel: i => i.UniqueName + " (x)"); + + Open(cut); + + var rows = cut.FindAll("[data-test='t-row']").Select(r => r.TextContent.Trim()).ToArray(); + Assert.Equal(new[] { "Line1", "Zulu (x)" }, rows); + + ClickRow(cut, "Line1"); + ClickRow(cut, "Cell A"); + cut.Find("[data-instance-id='7']").Click(); + + Assert.Equal("P1 (x)", InputValue(cut)); + } + + [Fact] + public void Typing_Filters_AndExpandsToTheMatches() + { + var cut = RenderPicker(); + Open(cut); + + Type(cut, "p1"); + + // The match's ancestor areas are force-expanded, so its row renders without a click. + Assert.Single(cut.FindAll("[data-instance-id='7']")); + + var rows = Rows(cut); + Assert.Contains("Line1", rows); + Assert.Contains("Cell A", rows); + Assert.DoesNotContain("Zulu", rows); + } + + [Fact] + public void Clearing_RestoresTheFullTree_Collapsed() + { + var cut = RenderPicker(); + Open(cut); + + Type(cut, "p1"); + Assert.Single(cut.FindAll("[data-instance-id='7']")); + + Type(cut, string.Empty); + + Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut)); + Assert.Empty(cut.FindAll("[data-instance-id='7']")); + } + + [Fact] + public void Typing_OpensTheClosedPanel() + { + var cut = RenderPicker(); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + + Type(cut, "z"); + + Assert.Single(cut.FindAll("[data-test='t-panel']")); + Assert.Equal(new[] { "Zulu" }, Rows(cut)); + } + + [Fact] + public void Enter_WithNoMatch_DoesNothing() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + Open(cut); + + Type(cut, "zzz"); + Press(cut, "Enter"); + + Assert.Null(picked); + Assert.Single(cut.FindAll("[data-test='t-panel']")); + } + + [Fact] + public void Enter_PicksTheFirstVisibleMatch() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + Open(cut); + + // "u" matches Zulu only — P1 drops out along with its now-empty ancestor areas. + Type(cut, "u"); + Press(cut, "Enter"); + + Assert.Equal(8, picked); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("Zulu", InputValue(cut)); + } + + [Fact] + public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + + Open(cut); + Press(cut, "Enter"); + + // Areas render before root instances, but every area is collapsed — so the first + // instance the operator can actually see is the root-level Zulu, not P1. + Assert.Equal(8, picked); + Assert.Equal("Zulu", InputValue(cut)); + } + + [Fact] + public void Escape_ClosesAndRestoresTheSelectedText() + { + var picked = 0; + var cut = RenderPicker(onChanged: id => picked = id); + + Open(cut); + ClickRow(cut, "Line1"); + ClickRow(cut, "Cell A"); + cut.Find("[data-instance-id='7']").Click(); + cut.Render(ps => ps.Add(p => p.SelectedInstanceId, picked)); + Assert.Equal("P1", InputValue(cut)); + + Open(cut); + Type(cut, "zzz"); + Assert.Equal("zzz", InputValue(cut)); + + Press(cut, "Escape"); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("P1", InputValue(cut)); + } + + [Fact] + public void ArrowDown_MovesFocusIntoTheTree() + { + var cut = RenderPicker(); + Open(cut); + + // The first row owns the tab stop by default, so move it off with the tree's own + // arrow navigation first — otherwise the assertion below could pass by accident. + ClickRow(cut, "Line1"); + cut.FindAll("[role='treeitem']")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + Assert.Equal("-1", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex")); + + Press(cut, "ArrowDown"); + + var items = cut.FindAll("[role='treeitem']"); + Assert.Equal("0", items[0].GetAttribute("tabindex")); + Assert.All(items.Skip(1), i => Assert.Equal("-1", i.GetAttribute("tabindex"))); + + // The tab stop above is the behavioural assertion; this is the belt-and-braces check + // that real browser focus was requested too. ElementReference.FocusAsync surfaces as a + // JS invocation under Loose mode — drop this line if a bUnit upgrade stops recording it. + Assert.Contains( + JSInterop.Invocations, + i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow() + { + // The tree does not exist yet when the keydown is handled, so this pins the deferred + // path: the panel opens in this render pass and the focus lands in the next one. + var cut = RenderPicker(); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + + Press(cut, "ArrowDown"); + + Assert.Single(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex")); + } + + [Fact] + public void Escape_WhenClosed_IsANoOp() + { + var cut = RenderPicker(selectedInstanceId: 8); + Assert.Equal("Zulu", InputValue(cut)); + + Press(cut, "Escape"); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("Zulu", InputValue(cut)); + } + + [Fact] + public void FilterIsNotCarriedIntoSelection() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + Open(cut); + + Type(cut, "p1"); + cut.Find("[data-instance-id='7']").Click(); + + Assert.Equal(7, picked); + Assert.Equal("P1", InputValue(cut)); + + Open(cut); + + Assert.Equal(new[] { "Line1", "Zulu" }, Rows(cut)); + Assert.Empty(cut.FindAll("[data-instance-id='7']")); + } + + [Fact] + public void Toggle_WhileOpen_ClosesThePanel() + { + var cut = RenderPicker(); + + Open(cut); + Assert.Single(cut.FindAll("[data-test='t-panel']")); + + Open(cut); + + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("false", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded")); + } + + [Fact] + public void TheFieldSitsAboveTheBackdrop_SoItStaysClickableWhileOpen() + { + // The backdrop is a fixed full-viewport layer; the stylesheet lifts the field above + // it through this class. Without it a click on the input or the chevron would land on + // the backdrop instead and close the panel. Only the class can be pinned here — the + // z-index itself is scoped CSS, verified visually. + var cut = RenderPicker(); + Open(cut); + + var field = cut.Find("[data-test='t-picker'] .input-group"); + Assert.Contains("ip-field", field.ClassList); + Assert.Single(cut.FindAll("[data-test='t-backdrop']")); + } + + [Fact] + public void TwoPickers_GetDistinctPanelIds() + { + // The panel id (and the field's aria-controls pointing at it) is minted per component + // instance, so two pickers on one page cannot collide on a DOM id. + var first = RenderPicker(); + var second = RenderPicker(); + + Open(first); + Open(second); + + var firstPanel = first.Find("[data-test='t-panel']").Id; + var secondPanel = second.Find("[data-test='t-panel']").Id; + + Assert.False(string.IsNullOrEmpty(firstPanel)); + Assert.NotEqual(firstPanel, secondPanel); + Assert.Equal(firstPanel, first.Find("[data-test='t-input']").GetAttribute("aria-controls")); + Assert.Equal(secondPanel, second.Find("[data-test='t-input']").GetAttribute("aria-controls")); + } + + [Fact] + public void SameInstancesReference_LeavesTheTypedTextAndOpenPanelAlone() + { + // Complement of SiteChange_NewInstancesList_ClearsAStaleSelectionText: an unrelated + // parameter change is NOT a site change, so a half-typed filter must survive it. + var cut = RenderPicker(); + Open(cut); + Type(cut, "zu"); + + cut.Render(ps => ps.Add(p => p.Placeholder, "Pick an instance")); + + Assert.Equal("zu", InputValue(cut)); + Assert.Single(cut.FindAll("[data-test='t-panel']")); + Assert.Equal(new[] { "Zulu" }, Rows(cut)); + } + + // -- Keyboard activation on the tree itself -------------------------------- + + [Fact] + public void KeyboardActivation_OnATreeRow_SelectsTheInstance() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + + Open(cut); + Press(cut, "ArrowDown"); + + // Areas render before root instances, so Zulu is the SECOND treeitem - the first + // one is the Line1 area (see KeyboardActivation_OnAnAreaRow_TogglesIt). + TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" }); + + Assert.Equal(8, picked); + Assert.Empty(cut.FindAll("[data-test='t-panel']")); + Assert.Equal("Zulu", InputValue(cut)); + } + + [Fact] + public void KeyboardActivation_OnAnAreaRow_TogglesIt() + { + int? picked = null; + var cut = RenderPicker(onChanged: id => picked = id); + + Open(cut); + Press(cut, "ArrowDown"); + + TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" }); + + // An area activates as an expand, not as a pick: its children appear and the + // panel stays open with no callback raised. + Assert.Contains("Cell A", Rows(cut)); + Assert.Single(cut.FindAll("[data-test='t-panel']")); + Assert.Null(picked); + + // The same key on the same row closes it again - activation is a toggle. + TreeItem(cut, 0).KeyDown(new KeyboardEventArgs { Key = "Enter" }); + Assert.DoesNotContain("Cell A", Rows(cut)); + } + + [Fact] + public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection() + { + // The label click and Enter both run through TreeView's single selection path, + // so neither input mode may double-fire the callback. + var picks = new List(); + var cut = RenderPicker(onChanged: picks.Add); + + Open(cut); + ClickRow(cut, "Zulu"); + Assert.Equal(new[] { 8 }, picks); + + Open(cut); + TreeItem(cut, 1).KeyDown(new KeyboardEventArgs { Key = "Enter" }); + Assert.Equal(new[] { 8, 8 }, picks); + } + + // -- Accessible name / labelling ------------------------------------------- + + [Fact] + public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter() + { + var cut = RenderPicker(); + Assert.Equal("Instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label")); + + cut.Render(ps => ps.Add(p => p.AriaLabel, "Target instance")); + Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label")); + } + + [Fact] + public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter() + { + // A page that wants its own