11 Commits

Author SHA1 Message Date
Joseph Doherty 68f911e634 docs: note alarmOverrides in GetInstanceDocumentAsync; mark template-alarm/override plan complete 2026-06-07 10:31:02 -04:00
Joseph Doherty 5bc8dbad31 test(playwright): Notification hygiene — scoped pager locator, next-enabled re-assert, role-mapping-delete doc note 2026-06-07 10:22:54 -04:00
Joseph Doherty d3adf8c2e4 test(playwright): SiteCalls hygiene — site-a seeds where grid-visible, scoped pager locator, next-enabled re-assert 2026-06-07 10:20:33 -04:00
Joseph Doherty f78086334f test(playwright): InstanceConfigure alarm-override set-priority/clear round-trip; drop stale TODO 2026-06-07 10:17:27 -04:00
Joseph Doherty c3d7d8a6a4 test(playwright): provision a HiLo alarm in InstanceConfigureFixture (via typed CLI flags) 2026-06-07 10:13:45 -04:00
Joseph Doherty bc8960779b feat(ui): add data-test hooks to InstanceConfigure alarm-override section 2026-06-07 10:10:50 -04:00
Joseph Doherty c84eb5aeef docs(cli): note intentional omission of HiLo per-setpoint priorities/deadbands/messages (review fix) 2026-06-07 10:06:58 -04:00
Joseph Doherty f0b144ebda test(playwright): CliRunner AddAlarm + alarm-override-delete helpers + round-trip (typed flags) 2026-06-07 10:05:32 -04:00
Joseph Doherty bbc3804d07 feat(cli): typed setpoint flags for template alarm add (serializes trigger-config JSON) 2026-06-07 10:02:51 -04:00
Joseph Doherty 9d7e69056a docs(plans): add template-alarm CLI + alarm-override coverage implementation plan 2026-06-07 10:00:48 -04:00
Joseph Doherty 475bfadacd docs(plans): design for template-alarm CLI ergonomics + alarm-override coverage 2026-06-07 09:53:34 -04:00
12 changed files with 947 additions and 17 deletions
@@ -0,0 +1,110 @@
# Template-Alarm CLI Ergonomics + InstanceConfigure Alarm-Override Coverage — Design
**Date:** 2026-06-07
**Status:** Approved (brainstorming complete) → ready for writing-plans
**Component:** #9 Central UI (`InstanceConfigure`) + the `scadabridge` CLI + the Playwright harness
**Predecessor / context:** closes the one functional gap deferred across the [4-wave coverage-fill effort](2026-06-06-playwright-coverage-fill-design.md) (alarm-override UI), plus the cheap Wave-4 review-hygiene items.
## Goal
Three sequential pieces of work:
- **(A)** Make template alarms *ergonomically* CLI-provisionable — add typed setpoint flags to the existing `template alarm add` verb so callers don't hand-write trigger-config JSON; add a `CliRunner.AddAlarmAsync` test helper.
- **(B)** Add the deferred **alarm-override UI coverage** on `InstanceConfigure` (Edit → set priority override → Save → badge → Clear), gated on additive `data-test` hooks.
- **(C)** Tackle the cheap, safe Wave-4 hygiene items.
## Premise correction (verified during brainstorming)
The Wave-1 deferral TODO (`InstanceConfigureTests.cs`) says *"template alarms are not CLI-provisionable today."* **That is STALE.** The CLI already has a fully-wired `template alarm add | update | delete`:
```
template alarm add --template-id <id> --name <n> \
--trigger-type <ValueMatch|RangeViolation|RateOfChange|HiLo|Expression> \
--priority <0-1000> [--description <d>] [--trigger-config <json>] [--locked]
```
End-to-end path (no cluster change needed to add an alarm):
`CLI BuildAlarm``AddTemplateAlarmCommand` DTO → `POST /management``ManagementActor.HandleAddAlarm` (role-gated `Designer`) → `TemplateService.AddAlarmAsync``ITemplateEngineRepository.AddTemplateAlarmAsync` + audit.
So the only *real* gaps are: (1) no ergonomic typed flags (you must hand-write `--trigger-config` JSON for setpoints); (2) no `CliRunner.AddAlarmAsync` test helper; (3) the `InstanceConfigure` alarm-override section is hook-poor and untested.
## Verified domain facts (grounding for the plan)
**Alarm entity — `TemplateAlarm`** (`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAlarm.cs`, table `TemplateAlarms`):
- Child of **`Template`** (FK `TemplateId`), NOT of `TemplateAttribute`. Binds to an attribute *by name* inside the `TriggerConfiguration` JSON (`attributeName` key).
- Fields: `Id`, `TemplateId`, `Name` (unique within template), `Description?`, `PriorityLevel` (01000), `IsLocked`, `TriggerType` (`AlarmTriggerType` enum), `TriggerConfiguration?` (JSON ≤4000 chars), `OnTriggerScriptId?`, `IsInherited`, `LockedInDerived`.
- `AlarmTriggerType` enum: `ValueMatch | RangeViolation | RateOfChange | HiLo | Expression`.
- Minimal valid alarm: `Name` + `TriggerType` + `PriorityLevel`. No trigger-config required to make a row exist (it just won't *fire*); an unlocked alarm is enough to render an override row.
**Trigger-config JSON shapes** (authoritative key names from `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs`, which is `internal`):
- `ValueMatch``{ attributeName, matchValue }` (a `"!=X"` prefix on matchValue means not-equals)
- `RangeViolation``{ attributeName, min, max }`
- `RateOfChange``{ attributeName, thresholdPerSecond, windowSeconds, direction }`
- `HiLo``{ attributeName, loLo, lo, hi, hiHi, loLoPriority, loPriority, hiPriority, hiHiPriority, *Deadband, *Message }`
- `Expression``{ expression }` (no attributeName)
**Instance alarm override — `InstanceAlarmOverride`** (`src/.../Commons/Entities/Instances/InstanceAlarmOverride.cs`, table `InstanceAlarmOverrides`):
- Fields: `Id`, `InstanceId` (FK, cascade), `AlarmCanonicalName` (matches `TemplateAlarm.Name` for direct alarms), `TriggerConfigurationOverride?` (JSON; partial-merge for HiLo, whole-replace otherwise), `PriorityLevelOverride?`. Unique `(InstanceId, AlarmCanonicalName)`. No "enabled" flag — only trigger-config and/or priority.
- Save/clear service: `InstanceService.SetAlarmOverrideAsync` / `DeleteAlarmOverrideAsync`. CLI verbs already exist: `instance alarm-override set|delete|list`.
- **Read-back:** `GetInstanceByIdAsync` eager-loads `.Include(i => i.AlarmOverrides)`, so the existing `CliRunner.GetInstanceDocumentAsync` (`instance get`) surfaces an `alarmOverrides` array alongside the `connectionBindings`/`attributeOverrides` the current tests already read. (Plan must confirm the exact camelCase JSON key.)
**InstanceConfigure alarm-override UI** (`src/.../CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`, single-file `@code`):
- Section identified only by header text `<strong>Alarm Overrides</strong>`**no `data-test` hooks anywhere in the alarm section** (the whole file has only 4 hooks: `audit-link`, `instance-error-alert`, `binding-bulk-select`, `area-select`). `AlarmTriggerEditor.razor` has zero hooks.
- Rows render from `_overridableAlarms = alarms.Where(a => !a.IsLocked)`. Empty → `"No overridable (non-locked) alarms on this template."` So the fixture template **must carry a non-locked alarm**.
- Per row: `Edit` button → opens a modal (`@if (_editingAlarm != null)``.modal.show.d-block`). Modal has an `<AlarmTriggerEditor>` + a **priority override** `<input type=number min=0 max=1000>` (placeholder = inherited priority) + footer `Cancel` / `Save Override` (`.btn-success.btn-sm`) / `Clear Override` (when an override exists).
- **Save semantics:** if the trigger-config diff is empty AND the priority field is empty, Save **deletes** any existing override. So the test's reliable "create override" delta is **setting the priority field**.
- Override-set badge: `<span class="badge bg-warning text-dark">●</span>` in the row, shown only when `HasOverride(name)`. Row-level `Clear` button (`.btn-outline-danger.btn-sm`) → `ClearAlarmOverride``DeleteAlarmOverrideAsync` **immediately, no confirm dialog**; toast `"Cleared override on '{name}'."`
**Existing fixture/test** (`tests/.../Deployment/InstanceConfigureFixture.cs` / `InstanceConfigureTests.cs`):
- Fixture (CLI-provisioned on real `site-a`, instance **not deployed**): template + one `Double` attribute `"Value"` (`AddAttributeAsync`, `--data-source "Value"`) + data-connection + area + instance. Public surface: `int SiteAId/TemplateId/ConnectionId/AreaId/InstanceId`, `string AttributeName => "Value"`, `string ConnectionName`, `bool Available`.
- Test idioms: `[Collection("Playwright")]` + `IClassFixture<InstanceConfigureFixture>`; `Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason)`; `_fixture.NewAuthenticatedPageAsync("multi-role","password")`; web-first `Assertions.Expect(...).ToBeVisibleAsync(new(){Timeout=15_000})`; toast `Expect(.toast).ToHaveCountAsync(1)`; persistence via `using var doc = await CliRunner.GetInstanceDocumentAsync(InstanceId)`.
**Build/transport reality:**
- CLI is a **host-side** client (`OutputType=Exe`, only references `Commons`); runs from `src/.../CLI/bin/Debug/net10.0/scadabridge.dll`. A CLI-only change needs just `dotnet build src/ZB.MOM.WW.ScadaBridge.CLI`.
- `ManagementActor`/`TemplateService`/CentralUI run in the cluster image; a `.razor` change needs `bash docker/deploy.sh` to reach the running cluster the tests hit.
## Decisions (settled during brainstorming)
| # | Decision | Choice | Rationale |
|---|----------|--------|-----------|
| D1 | Part-A scope | **Typed CLI flags + test helper** (not just the helper) | User wants the ergonomic enhancement; CLI serializes the JSON. CLI-only, no server change. |
| D2 | Trigger-config serializer location | **New CLI-side `AlarmTriggerConfigJson`** (don't move the UI's `internal` codec) | Keeps the change CLI-only / no docker rebuild; the round-trip test verifies the JSON against the live server (the real contract). Cross-reference the codec for key names. |
| D3 | Part-B selectors | **Add 6 additive `data-test` hooks**`bash docker/deploy.sh` | Matches the existing InstanceConfigure convention (`binding-bulk-select`/`area-select`); removes selector drift (esp. the priority input, which collides with `AlarmTriggerEditor` number inputs). |
| D4 | Override-test delta | **Priority override** (not trigger-config) | Simplest reliable "create override" path; a no-delta save deletes instead. |
| D5 | Fixture alarm type | **HiLo** via the new typed flags (`--attribute Value --hi 80 --hihi 95`) | Dogfoods the Part-A flags end-to-end. (Any non-locked alarm works for the override row.) |
| D6 | Part-C scope | **Cheap & safe only** | `plant-a→site-a`, locator scoping, next-enabled re-assert, doc note. Leave the harmless suite-wide `WaitForLoadState` pattern. |
## Architecture / components
### Part A — CLI ergonomic alarm flags + test helper *(host-side build only)*
1. **`AlarmTriggerConfigJson` (new, CLI project):** `Build(triggerType, attribute, matchValue, notEquals, min, max, thresholdPerSecond, windowSeconds, direction, loLo, lo, hi, hiHi, expression) → string?` emitting the exact camelCase keys from `AlarmTriggerConfigCodec`. Only emits fields relevant to the trigger type; returns `null` when no typed flags are given. Comment cross-references the codec as the source of truth.
2. **`template alarm add` typed options** in `CLI/Commands/TemplateCommands.cs::BuildAlarm`: `--attribute`, `--match-value`, `--not-equals`, `--min`, `--max`, `--threshold-per-second`, `--window-seconds`, `--direction`, `--lolo`, `--lo`, `--hi`, `--hihi`, `--expression`. When any are present, the handler builds the JSON and sets it as `AddTemplateAlarmCommand.TriggerConfiguration`. Raw `--trigger-config` (if supplied) takes precedence. **No new DTO, no server change.** (HiLo per-level priorities/deadbands/messages stay JSON-only — YAGNI.)
3. **`CliRunner.AddAlarmAsync(int templateId, string name, string triggerType = "HiLo", int priority = 500, string? attribute = null, double? hi = null, double? hiHi = null, ...)`** in `CliRunner.Helpers.cs` (mirrors `AddAttributeAsync`; throws via `RequireId`).
4. **Round-trip `[SkippableFact]`** in `CliRunnerHelpersTests.cs`: create a `zztest` template → `AddAlarmAsync` a HiLo via typed flags → assert returned id > 0 (proves the server accepted the serialized JSON) → `finally` delete template (cascade).
### Part B — alarm-override coverage *(app change → one docker rebuild)*
1. **`InstanceConfigure.razor` hooks (additive, non-functional):** `data-test="alarm-override-row-{alarm.Name}"` (row), `alarm-edit-btn` (Edit), `alarm-priority-input` (modal priority input), `alarm-save-override` (Save Override), `alarm-override-badge` (●), `alarm-clear-btn` (row Clear). **`bash docker/deploy.sh`** so the live UI exposes them.
2. **`InstanceConfigureFixture`:** after `AddAttributeAsync`, call `CliRunner.AddAlarmAsync(TemplateId, AlarmName, "HiLo", priority:500, attribute:"Value", hi:80, hiHi:95)`; expose `string AlarmName`. No teardown (template delete cascades the alarm).
3. **`AlarmOverride_SetPriority_ThenClear_RoundTrips` (extends `InstanceConfigureTests.cs`):** navigate to the configure page → assert the alarm row shows "inherited" → `alarm-edit-btn` → fill `alarm-priority-input` = `750``alarm-save-override` → assert `.toast` count 1 + `alarm-override-badge` visible → CLI read-back `GetInstanceDocumentAsync` shows `alarmOverrides[]` with `priorityLevelOverride == 750``alarm-clear-btn` → assert badge gone + read-back empty. `finally`: best-effort `instance alarm-override delete` (new `CliRunner.DeleteInstanceAlarmOverrideAsync` helper) so the **shared fixture instance is left as found**.
### Part C — hygiene (cheap & safe)
- `sourceSite:"plant-a"``"site-a"` in the older SiteCalls/Notification seeding tests.
- Scope the pager-indicator locators (`span.text-muted.small` → pager-container-scoped) in the SiteCalls + Notification pagination tests.
- Add the `next` enabled re-assert on the return-to-page-1 leg of both pagination tests.
- Add the "update all three-word deletes together" maintenance note to `DeleteRoleMappingAsync`.
- Test-only; verified by re-running the affected suites.
## Error handling & verification
- **Validation-behavior protocol** still applies: before asserting the override badge/persistence, the implementer reads the real save handler (a no-delta save deletes; priority is the reliable delta) and the `alarmOverrides` JSON key.
- **Build map:** A → `dotnet build src/...CLI` (host) + test-project build; B → **`bash docker/deploy.sh`** (the only docker rebuild, for the hooks) then test-project build; C → test-project build.
- **Residue & shared-state:** alarm round-trip uses a `zztest` template (cascade-deleted). The override test mutates the *shared* fixture instance's alarm override and clears it (+ `finally` cleanup) → instance left as found; the fixture's `DisposeAsync` tears down template/connection/area/instance. No other InstanceConfigure test touches alarm overrides; serial collection.
- **Per-part gate:** each part ends with its new tests green against the live cluster, the full suite at 0 failed, zero residue, clean build under `TreatWarningsAsErrors`. Part B additionally confirms the `data-test` additions don't alter rendered behavior.
## Scope guard (YAGNI)
No new server command/DTO (Part A rides the existing `AddTemplateAlarmCommand`). No HiLo per-level priority/deadband/message flags (raw JSON escape hatch remains). No suite-wide `WaitForLoadState` refactor. The `data-test` hooks are the only app-code change and are purely additive. No new fixtures beyond extending `InstanceConfigureFixture`.
## Success criteria
`template alarm add` accepts typed setpoint flags (verified by a round-trip test); `InstanceConfigure`'s alarm-override flow (set-priority → badge → clear) has functional coverage asserting persistence via CLI read-back; the cheap hygiene items are applied; full suite green with logged skips; zero residue; `site-a` and the shared fixture instance left as found.
@@ -0,0 +1,521 @@
# Template-Alarm CLI Ergonomics + Alarm-Override Coverage — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Add ergonomic typed setpoint flags to the existing `template alarm add` CLI verb, then close the deferred `InstanceConfigure` alarm-override UI coverage gap (gated on additive `data-test` hooks), then apply the cheap Wave-4 hygiene items.
**Architecture:** Part A is **CLI-only** (host-side `dotnet build`) — a new CLI-side `AlarmTriggerConfigJson` serializer + typed options on `BuildAlarm` that ride the *existing* `AddTemplateAlarmCommand` DTO (no server change). Part B adds 6 non-functional `data-test` hooks to `InstanceConfigure.razor` (**one `bash docker/deploy.sh` rebuild**), gives `InstanceConfigureFixture` an alarm, and adds one override round-trip test. Part C is test-only hygiene.
**Tech Stack:** C# / System.CommandLine (CLI), Blazor Server (CentralUI), xUnit + `Xunit.SkippableFact` + Microsoft.Playwright, the `scadabridge` CLI. TFM `net10.0`, `Nullable=enable`, `TreatWarningsAsErrors=true`.
---
## Grounding facts (verified — do NOT re-derive)
**The premise was stale.** `template alarm add | update | delete` already exist and work end-to-end (CLI → `POST /management``ManagementActor.HandleAddAlarm``TemplateService.AddAlarmAsync` → repo + audit). The only gaps: no typed setpoint flags (must hand-write `--trigger-config` JSON), no `CliRunner.AddAlarmAsync` helper, and the alarm-override UI is hook-poor + untested.
**Codec key names** (the contract — from `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs`, `internal`, do NOT move it):
- `ValueMatch``{ attributeName, matchValue }` (matchValue `"!=X"` ⇒ not-equals)
- `RangeViolation``{ attributeName, min, max }`
- `RateOfChange``{ attributeName, thresholdPerSecond, windowSeconds, direction }` (direction normalized to `rising` | `falling` | `either`)
- `HiLo``{ attributeName, loLo, lo, hi, hiHi, ... }` (only the setpoints present are emitted)
- `Expression``{ expression }` (no attributeName)
The codec's `Serialize` writes `attributeName` for every type except `Expression`, and writes only numeric keys whose value `HasValue`.
**CLI `BuildAlarm`** (`src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:216-296`): the `add` subcommand registers `--template-id --name --trigger-type --priority --description --trigger-config --locked` and its `SetAction` builds `new AddTemplateAlarmCommand(templateId, name, triggerType, priority, description, triggerConfig, locked)` via `CommandHelpers.ExecuteCommandAsync`. DTO: `AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked)` (`Commons/Messages/Management/TemplateCommands.cs:14`). The CLI is host-side (`OutputType=Exe`, references only `Commons`); a CLI change needs `dotnet build src/ZB.MOM.WW.ScadaBridge.CLI`.
**Test-helper patterns** (`tests/.../Cluster/CliRunner.Helpers.cs`): `CreateTemplateAsync` (line 50, `RunJsonAsync` + `RequireId(doc, "template create")`), `AddAttributeAsync` (line 82, builds a `List<string>` of args + `RunAsync` — returns void, uses `--data-source` only when set), `DeleteTemplateAsync` (line 210, `BestEffortAsync("template","delete",id)`), `GetInstanceDocumentAsync(int id)` (line 408), `RequireId(JsonDocument, string command)` (line 689). Three-token best-effort deletes are written inline (try/catch swallow) like `DeleteAreaAsync`/`DeleteRoleMappingAsync` (the shared `BestEffortAsync` only models two-token groups).
**InstanceConfigure alarm-override markup** (`src/.../CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`), the 6 hook sites:
- Row `<tr>` at **line 248** (inside `@foreach (var alarm in _overridableAlarms)`).
- Edit button at **lines 269-271** (`@onclick="() => BeginEditOverride(alarm)"`).
- Override badge `<span class="badge bg-warning text-dark me-1">●</span>` at **line 260** (rendered only when `HasOverride(alarm.Name)`).
- Row Clear button at **lines 274-276** (rendered only when `HasOverride`).
- Modal priority input `<input type="number" min="0" max="1000" ... @bind="_editingPriorityText" ...>` at **lines 320-322**.
- Modal Save button `<button class="btn btn-success btn-sm" @onclick="SaveOverrideFromModal" ...>Save Override</button>` at **line 342**.
The section only renders rows for `_overridableAlarms = alarms.Where(a => !a.IsLocked)` — so the fixture template MUST carry a non-locked alarm, else the card shows `"No overridable (non-locked) alarms on this template."` The modal opens on Edit (`@if (_editingAlarm != null)``.modal.show.d-block`). **Save with no config-diff AND empty priority DELETES the override** — so the test's reliable "create override" delta is **setting the priority field**. Clear is **immediate, no confirm dialog** (toast `"Cleared override on '{name}'."`).
**Fixture** (`tests/.../Deployment/InstanceConfigureFixture.cs`): `InitializeAsync` does (on `site-a`, instance NOT deployed): `CreateTemplateAsync(UniqueName("cfgtmpl"))``AddAttributeAsync(TemplateId, "Value", "Double", dataSourceReference:"Value")``CreateDataConnectionAsync``CreateAreaAsync``CreateInstanceAsync`. Public: `SiteAId/TemplateId/ConnectionId/AreaId/InstanceId` (int), `AttributeName => "Value"`, `ConnectionName`, `Available`. `SafeCleanupAsync` deletes instance→connection→area→template (template delete cascades children).
**Test idioms** (`tests/.../Deployment/InstanceConfigureTests.cs`): `[Collection("Playwright")]` + `IClassFixture<InstanceConfigureFixture>`, ctor injects `PlaywrightFixture _fixture` + `InstanceConfigureFixture _cfg`; `Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason)`; `_fixture.NewAuthenticatedPageAsync("multi-role","password")`; navigate `{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure`; toast `Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new(){Timeout=15_000})`; persistence read-back e.g. `using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId); var overrides = doc.RootElement.GetProperty("attributeOverrides"); ...o.GetProperty("attributeName").GetString()...`. The stale TODO is at **line 169**. Alarm overrides are eager-loaded on the instance document → expect a sibling `alarmOverrides` array (each element: `alarmCanonicalName`, `priorityLevelOverride`, `triggerConfigurationOverride`) — confirm the exact camelCase key by reading what the document serializer emits, falling back to `instance alarm-override list` if absent.
**Part C targets** (verified line numbers):
- `plant-a` seeds in `SiteCalls/SiteCallsPageTests.cs`: lines **125, 129, 179, 229, 234** (seed calls). Line **285** is a *comment* in a relay test that deliberately discusses the cosmetic `plant-a`/unknown-site behavior — **verify each occurrence's intent; only change seeds whose rows must appear in the filtered grid; leave any that intentionally test unknown-site/relay behavior.**
- Pager indicator `var ... = page.Locator("span.text-muted.small")`: `NotificationActionTests.cs:418`, `SiteCallsPageTests.cs:552`.
- Pagination back-leg (re-assert next): `SiteCallsPageTests.cs:572` (after the back-to-page-1 `prev` disabled, add `next` enabled); `NotificationActionTests.cs:437` (same).
- `DeleteRoleMappingAsync` doc note: `CliRunner.Helpers.cs` (the three-token delete helper added in Wave 4).
**Build map:** A = `dotnet build src/ZB.MOM.WW.ScadaBridge.CLI` (host) + test-project build; **B Task 2 = `bash docker/deploy.sh` (the only docker rebuild)**; B Tasks 3-4 + C = test-project build. **Cadence:** one shared cluster + browser + build → serialize every cluster-running/rebuild task; the docker rebuild in Task 2 must run with NO cluster test in flight (it recreates the containers).
**Native tasks:** #125132 (plan Task 07).
---
## Part A — CLI ergonomic alarm flags + test helper
### Task 0: AlarmTriggerConfigJson serializer + typed flags on `template alarm add`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CLI/AlarmTriggerConfigJson.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:216-250` (the `add` subcommand inside `BuildAlarm`)
**Step 1 — create `AlarmTriggerConfigJson`** (CLI-side serializer mirroring the codec's `Serialize`; emits the exact keys; returns `null` when no typed flags are supplied so a bare `alarm add` keeps working):
```csharp
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>
/// Serializes typed alarm-setpoint CLI flags into the trigger-config JSON the
/// server expects. Key names MUST stay in lockstep with the canonical codec at
/// src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs
/// (that codec is internal to CentralUI, so this is a deliberate CLI-side mirror;
/// the round-trip test verifies the JSON against the live server — the real contract).
/// </summary>
internal static class AlarmTriggerConfigJson
{
/// <summary>
/// Builds the trigger-config JSON for <paramref name="triggerType"/> from the typed
/// flags, or returns null when none are supplied (so the alarm is created without a
/// trigger config). Unknown/blank trigger types yield null.
/// </summary>
internal static string? Build(
string triggerType, string? attribute,
string? matchValue, bool notEquals,
double? min, double? max,
double? thresholdPerSecond, double? windowSeconds, string? direction,
double? loLo, double? lo, double? hi, double? hiHi,
string? expression)
{
var type = triggerType?.Trim();
// Nothing typed → no config (caller may still pass --trigger-config raw JSON).
var anyTyped = attribute is not null || matchValue is not null || notEquals
|| min.HasValue || max.HasValue || thresholdPerSecond.HasValue || windowSeconds.HasValue
|| direction is not null || loLo.HasValue || lo.HasValue || hi.HasValue || hiHi.HasValue
|| expression is not null;
if (!anyTyped) return null;
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
if (!string.Equals(type, "Expression", StringComparison.OrdinalIgnoreCase))
w.WriteString("attributeName", attribute ?? "");
switch (type?.ToLowerInvariant())
{
case "valuematch":
var mv = matchValue ?? "";
if (notEquals) mv = "!=" + mv;
w.WriteString("matchValue", mv);
break;
case "rangeviolation":
if (min.HasValue) w.WriteNumber("min", min.Value);
if (max.HasValue) w.WriteNumber("max", max.Value);
break;
case "rateofchange":
if (thresholdPerSecond.HasValue) w.WriteNumber("thresholdPerSecond", thresholdPerSecond.Value);
if (windowSeconds.HasValue) w.WriteNumber("windowSeconds", windowSeconds.Value);
w.WriteString("direction", NormalizeDirection(direction));
break;
case "hilo":
if (loLo.HasValue) w.WriteNumber("loLo", loLo.Value);
if (lo.HasValue) w.WriteNumber("lo", lo.Value);
if (hi.HasValue) w.WriteNumber("hi", hi.Value);
if (hiHi.HasValue) w.WriteNumber("hiHi", hiHi.Value);
break;
case "expression":
w.WriteString("expression", expression ?? "");
break;
}
w.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
// Mirrors AlarmTriggerConfigCodec.NormalizeDirection.
private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => "rising",
"falling" or "down" or "negative" => "falling",
_ => "either",
};
}
```
**Step 2 — add typed options to the `add` subcommand** in `BuildAlarm` (after the existing `lockedOption`, before `addCmd.SetAction`). Add the `Option<...>` declarations and `addCmd.Add(...)` for each, then thread them into the JSON build:
```csharp
var attributeOption = new Option<string?>("--attribute") { Description = "Monitored attribute name (not for Expression)" };
var matchValueOption = new Option<string?>("--match-value") { Description = "ValueMatch: value to match" };
var notEqualsOption = new Option<bool>("--not-equals") { Description = "ValueMatch: invert the match" };
notEqualsOption.DefaultValueFactory = _ => false;
var minOption = new Option<double?>("--min") { Description = "RangeViolation: minimum" };
var maxOption = new Option<double?>("--max") { Description = "RangeViolation: maximum" };
var thresholdOption = new Option<double?>("--threshold-per-second") { Description = "RateOfChange: threshold per second" };
var windowOption = new Option<double?>("--window-seconds") { Description = "RateOfChange: window seconds" };
var directionOption = new Option<string?>("--direction") { Description = "RateOfChange: rising|falling|either" };
var loLoOption = new Option<double?>("--lolo") { Description = "HiLo: low-low setpoint" };
var loOption = new Option<double?>("--lo") { Description = "HiLo: low setpoint" };
var hiOption = new Option<double?>("--hi") { Description = "HiLo: high setpoint" };
var hiHiOption = new Option<double?>("--hihi") { Description = "HiLo: high-high setpoint" };
var expressionOption = new Option<string?>("--expression") { Description = "Expression: boolean expression" };
// addCmd.Add(...) for each of the above.
```
Then in `addCmd.SetAction`, derive the trigger config — **raw `--trigger-config` wins; otherwise build from typed flags**:
```csharp
addCmd.SetAction(async (ParseResult result) =>
{
var triggerType = result.GetValue(triggerTypeOption)!;
var rawConfig = result.GetValue(triggerConfigOption);
var triggerConfig = rawConfig ?? AlarmTriggerConfigJson.Build(
triggerType,
result.GetValue(attributeOption),
result.GetValue(matchValueOption), result.GetValue(notEqualsOption),
result.GetValue(minOption), result.GetValue(maxOption),
result.GetValue(thresholdOption), result.GetValue(windowOption), result.GetValue(directionOption),
result.GetValue(loLoOption), result.GetValue(loOption), result.GetValue(hiOption), result.GetValue(hiHiOption),
result.GetValue(expressionOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAlarmCommand(
result.GetValue(templateIdOption), result.GetValue(nameOption)!,
triggerType, result.GetValue(priorityOption)!,
result.GetValue(descOption), triggerConfig, result.GetValue(lockedOption)));
});
```
Leave `update`/`delete` untouched (YAGNI). No new DTO, no server change.
**Step 3 — build the CLI:** `cd /Users/dohertj2/Desktop/ScadaBridge && dotnet build src/ZB.MOM.WW.ScadaBridge.CLI` — clean under `TreatWarningsAsErrors`. (This rebuilds `bin/Debug/net10.0/scadabridge.dll`, which the tests shell.) Smoke: `dotnet build` succeeds; optionally `dotnet src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge.dll template alarm add --help` lists the new options.
**Step 4 — commit:** `git add src/ZB.MOM.WW.ScadaBridge.CLI/`; commit `feat(cli): typed setpoint flags for template alarm add (serializes trigger-config JSON)`.
**Acceptance:** CLI builds warning-clean; `template alarm add` accepts the typed flags; raw `--trigger-config` still takes precedence; a bare add (no typed flags) still produces `null` config. No server/`Commons` change (`git diff --stat` shows only `src/...CLI/`).
---
### Task 1: CliRunner.AddAlarmAsync + DeleteInstanceAlarmOverrideAsync helpers + round-trip test
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Blocked by:** Task 0 (the CLI must be rebuilt with the typed flags before the round-trip exercises them).
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs`
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs`
**Step 1 — add `AddAlarmAsync`** (returns the new alarm id via `RunJsonAsync` + `RequireId`, mirroring `CreateTemplateAsync`; passes typed flags through):
```csharp
/// <summary>
/// Adds an alarm to a template via <c>template alarm add</c> (using the typed setpoint
/// flags) and returns its new <c>id</c>. Throws on failure.
/// </summary>
public static async Task<int> AddAlarmAsync(
int templateId, string name, string triggerType = "HiLo", int priority = 500,
string? attribute = null, double? hi = null, double? hiHi = null,
double? lo = null, double? loLo = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List<string>
{
"template", "alarm", "add",
"--template-id", templateId.ToString(inv),
"--name", name,
"--trigger-type", triggerType,
"--priority", priority.ToString(inv),
};
if (attribute is not null) { args.Add("--attribute"); args.Add(attribute); }
if (hi.HasValue) { args.Add("--hi"); args.Add(hi.Value.ToString(inv)); }
if (hiHi.HasValue) { args.Add("--hihi"); args.Add(hiHi.Value.ToString(inv)); }
if (lo.HasValue) { args.Add("--lo"); args.Add(lo.Value.ToString(inv)); }
if (loLo.HasValue) { args.Add("--lolo"); args.Add(loLo.Value.ToString(inv)); }
using var doc = await RunJsonAsync([.. args]);
return RequireId(doc, "template alarm add");
}
```
**Step 2 — add `DeleteInstanceAlarmOverrideAsync`** (best-effort teardown for Part B; three-token group → inline try/catch, like `DeleteAreaAsync`):
```csharp
/// <summary>Best-effort delete of an instance alarm override (teardown). Never throws.</summary>
public static async Task DeleteInstanceAlarmOverrideAsync(int instanceId, string alarmCanonicalName)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
try
{
await RunAsync("instance", "alarm-override", "delete",
"--instance-id", instanceId.ToString(inv), "--alarm", alarmCanonicalName);
}
catch { /* best-effort teardown — never mask the test's own failure. */ }
}
```
**Step 3 — round-trip test** in `CliRunnerHelpersTests.cs` (mirror `CreateThenDeleteRoleMapping_RoundTrips`; exercises the typed HiLo flags):
```csharp
[SkippableFact]
public async Task AddAlarmWithTypedFlags_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var id = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("tmpl"));
try
{
await CliRunner.AddAttributeAsync(id, "Value", "Double");
var alarmId = await CliRunner.AddAlarmAsync(id, "HiHi", "HiLo", 500, attribute: "Value", hi: 80, hiHi: 95);
Assert.True(alarmId > 0); // server accepted the serialized trigger-config JSON
}
finally { await CliRunner.DeleteTemplateAsync(id); }
}
```
**Step 4 — run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter AddAlarmWithTypedFlags_RoundTrips`. Expected PASS (or SKIP if cluster down). **The CLI must be built (Task 0) first** — the test shells the rebuilt dll.
**Step 5 — commit:** `git add` the two files; commit `test(playwright): CliRunner AddAlarm + alarm-override-delete helpers + round-trip (typed flags)`.
**Acceptance:** helpers compile warning-clean; round-trip passes against the live cluster (proves the typed flags serialize to JSON the server accepts); zero `zztest-tmpl-*` residue.
---
## Part B — alarm-override coverage (app change → ONE docker rebuild)
### Task 2: data-test hooks on the InstanceConfigure alarm section + docker rebuild
**Classification:** standard
**Estimated implement time:** ~4 min (edit) + the `docker/deploy.sh` rebuild wall-time
**Parallelizable with:** none
**Blocked by:** Task 1 (keeps Part A's cluster round-trip ahead of the cluster-recreating rebuild).
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (lines 248, 260, 269-271, 274-276, 320-322, 342)
**Step 1 — add the 6 additive, non-functional `data-test` attributes** (do NOT change any behavior/markup beyond adding the attribute):
- Line 248 row: `<tr data-test="alarm-override-row-@alarm.Name">`
- Line 260 badge: `<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>`
- Lines 269-271 Edit button: add `data-test="alarm-edit-btn"` to the `<button class="btn btn-outline-primary btn-sm me-1" ...>Edit</button>`.
- Lines 274-276 row Clear button: add `data-test="alarm-clear-btn"` to the `<button class="btn btn-outline-danger btn-sm" ...>Clear</button>`.
- Lines 320-322 priority input: add `data-test="alarm-priority-input"` to the `<input type="number" min="0" max="1000" ...>`.
- Line 342 Save button: add `data-test="alarm-save-override"` to `<button class="btn btn-success btn-sm" @onclick="SaveOverrideFromModal" ...>Save Override</button>`.
(The row badge/Clear are scoped per row; the Edit button is unique per row too. Since the fixture has exactly one alarm, page-level `[data-test='alarm-edit-btn']` etc. resolve uniquely — but the row uses `alarm-override-row-@alarm.Name` for explicit scoping if ever needed.)
**Step 2 — rebuild the cluster image so the live UI exposes the hooks:** `cd /Users/dohertj2/Desktop/ScadaBridge && bash docker/deploy.sh`. This is the **only** docker rebuild in this plan; it recreates the 8-node cluster, so **no other cluster test may run during it**. Wait for it to finish and the cluster to be healthy (the next task's `ClusterAvailability` gate will confirm).
**Step 3 — verify the hooks rendered (optional sanity):** after the rebuild, the alarm section in the running UI exposes the 6 hooks (Task 4 will exercise them). Confirm the build itself was clean (deploy.sh builds the image under the same warnings-as-errors).
**Step 4 — commit:** `git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`; commit `feat(ui): add data-test hooks to InstanceConfigure alarm-override section`.
**Acceptance:** the 6 hooks are present and additive only (no rendered-behavior change); image rebuilt and cluster healthy. `git diff --stat` for this task shows only `InstanceConfigure.razor`.
---
### Task 3: fixture alarm
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Blocked by:** Task 1 (needs `AddAlarmAsync`), Task 2 (cluster rebuilt — avoids the rebuild disrupting this task's cluster re-run).
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs`
**Step 1 — expose `AlarmName`** (next to `AttributeName`):
```csharp
/// <summary>The single non-locked alarm on the fixture template (for the override test).</summary>
public string AlarmName => "HiHi";
```
**Step 2 — provision the alarm** in `InitializeAsync`, right after the `AddAttributeAsync` call (line 60). Dogfoods the Part-A typed flags (HiLo on the `Value` Double):
```csharp
await CliRunner.AddAlarmAsync(TemplateId, AlarmName, "HiLo", priority: 500,
attribute: AttributeName, hi: 80, hiHi: 95);
```
No teardown change needed — `DeleteTemplateAsync` cascades the alarm.
**Step 3 — re-run the existing InstanceConfigure suite** to confirm the added alarm doesn't perturb the bindings/override/area tests: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~InstanceConfigureTests"`. Expected: all existing tests still PASS (the alarm is just another template child; the existing tests don't touch it).
**Step 4 — commit:** `git add tests/.../Deployment/InstanceConfigureFixture.cs`; commit `test(playwright): provision a HiLo alarm in InstanceConfigureFixture (via typed CLI flags)`.
**Acceptance:** fixture builds + provisions the alarm; existing InstanceConfigure tests still green; zero residue (cascade).
---
### Task 4: AlarmOverride_SetPriority_ThenClear_RoundTrips test
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Blocked by:** Task 2 (hooks live in the running cluster), Task 3 (fixture alarm).
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs` (add the test; remove the stale TODO at line 169)
**Step 1 — add the test** (priority override is the reliable delta; assert toast + badge + CLI read-back; Clear; assert badge gone + read-back empty; `finally` cleans the shared instance):
```csharp
/// <summary>
/// Alarm-override round-trip on InstanceConfigure: Edit the fixture's HiLo alarm, set a
/// priority override, Save → toast + "overridden" badge, verify the InstanceAlarmOverride
/// persisted via CLI read-back, then Clear → badge gone + override removed. The fixture
/// instance is SHARED, so the test clears its own override (+ finally) to leave it as found.
/// (Setting only the priority is the reliable "create override" delta — a no-delta Save
/// deletes the override instead. See InstanceConfigure.razor SaveOverrideFromModal.)
/// </summary>
[SkippableFact]
public async Task AlarmOverride_SetPriority_ThenClear_RoundTrips()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
// No override yet.
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
// Edit → modal → set a priority override → Save.
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var priorityInput = page.Locator("[data-test='alarm-priority-input']");
await Assertions.Expect(priorityInput).ToBeVisibleAsync();
await priorityInput.FillAsync("750");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
// Toast + the badge appears in the row (modal closed, re-rendered).
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Persistence via CLI read-back.
using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
{
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.Contains(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName
&& o.GetProperty("priorityLevelOverride").GetInt32() == 750);
}
// Clear (immediate, no confirm) → badge gone + override removed.
await row.Locator("[data-test='alarm-clear-btn']").ClickAsync();
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0, new() { Timeout = 15_000 });
using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
{
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
}
}
finally
{
// Shared fixture instance — guarantee no override leaks even if the test failed mid-way.
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
```
Remove the stale `// TODO(wave-N): alarm-override UI coverage ...` line (169).
**⚠ VALIDATION-BEHAVIOR PROTOCOL:** before finalizing, confirm the instance document's alarm-override key/shape. Read what `GetInstanceDocumentAsync` (`instance get` → the document serializer) actually emits: the array is expected to be `alarmOverrides` with elements `{ alarmCanonicalName, priorityLevelOverride, triggerConfigurationOverride }` (camelCase). If the key differs, assert the real key; if the document does NOT surface overrides, read back via `instance alarm-override list` instead (a `RunJsonAsync("instance","alarm-override","list","--instance-id", id)` call) and assert there. Also confirm the priority field name (`priorityLevelOverride`). Assert reality; note any deviation in a code comment.
**Step 2 — run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter AlarmOverride_SetPriority_ThenClear_RoundTrips`. Expected PASS. (Requires Task 2's rebuilt cluster so the hooks exist.)
**Step 3 — commit:** `git add tests/.../Deployment/InstanceConfigureTests.cs`; commit `test(playwright): InstanceConfigure alarm-override set-priority/clear round-trip; drop stale TODO`.
**Acceptance:** test passes; asserts persistence via CLI read-back both ways; the shared fixture instance is left override-free (badge gone + read-back empty + `finally`).
---
## Part C — cheap hygiene
### Task 5: SiteCalls hygiene (plant-a→site-a surgical + pager-indicator scope + next re-assert)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6 (disjoint files)
**Blocked by:** none (independent of A/B; may run any time, but cluster-serialized).
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs` (lines ~125,129,179,229,234,285,552,572)
**Step 1 — `plant-a` → `site-a` (SURGICAL).** For EACH `sourceSite: "plant-a"` seed (lines 125,129,179,229,234), read the surrounding test: if the seeded row must appear in the (permitted) grid for the assertion to hold — i.e. FilterNarrowing / DrillIn / RetryDiscard-visibility — change `"plant-a"``"site-a"` (removes the system-wide-user fragility). **Do NOT change** any occurrence whose test *intends* an unknown/cosmetic site (the comment at line 285 discusses this deliberately) — leave those and the comment as-is, or refine the comment if a seed it refers to changed. When unsure for a given line, keep `plant-a` and add a one-line comment explaining why it's intentional.
**Step 2 — scope the pager indicator** (line 552): change `page.Locator("span.text-muted.small")` to a pager-container-scoped locator. Read the `SiteCallsReport.razor` pager markup for the exact wrapper (e.g. `<div class="d-flex justify-content-between align-items-center">`); use e.g. `page.Locator(".d-flex.justify-content-between span.text-muted.small")` (or the real wrapper class). Future-proofs against another `span.text-muted.small` being added.
**Step 3 — re-assert `next` on the back-leg** (after line 572, where the test returns to page 1 and asserts `prev` disabled): add `await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeEnabledAsync();` so a regression that broke Next after a page-back is caught.
**Step 4 — run the SiteCalls suite:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~SiteCallsPageTests"`. Expected: all PASS.
**Step 5 — commit:** `git add tests/.../SiteCalls/SiteCallsPageTests.cs`; commit `test(playwright): SiteCalls hygiene — site-a seeds, scoped pager locator, next-enabled re-assert`.
**Acceptance:** SiteCalls suite green; only the intended `plant-a` (if any) remains, documented; pager locator scoped; back-leg asserts `next` enabled.
---
### Task 6: Notification hygiene (pager-indicator scope + next re-assert) + DeleteRoleMappingAsync doc note
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 5 (disjoint files)
**Blocked by:** none.
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs` (lines ~418, 437)
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs` (the `DeleteRoleMappingAsync` doc comment)
**Step 1 — scope the pager indicator** (line 418): same as Task 5 Step 2 but for `NotificationReport.razor`'s pager wrapper — change `page.Locator("span.text-muted.small")` to the pager-container-scoped locator (read the page for the exact wrapper class).
**Step 2 — re-assert `next` on the back-leg** (after line 437, the return-to-page-1 `prev` disabled): add `await Assertions.Expect(next).ToBeEnabledAsync();` (`next` is the existing pager-next locator in that test).
**Step 3 — add the maintenance note to `DeleteRoleMappingAsync`** (CliRunner.Helpers.cs): append to its XML `<remarks>` the same cross-reference the sibling three-token deletes carry, e.g. *"Three-token CLI group (`security role-mapping`) so it can't use the two-token `BestEffortAsync`; if the teardown idiom changes, update all inline three-token deletes (`DeleteAreaAsync`, `DeleteInstanceAlarmOverrideAsync`, this) together."*
**Step 4 — run the Notification suite:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~NotificationActionTests"`. Expected: all PASS.
**Step 5 — commit:** `git add tests/.../Notifications/NotificationActionTests.cs tests/.../Cluster/CliRunner.Helpers.cs`; commit `test(playwright): Notification hygiene — scoped pager locator, next-enabled re-assert, role-mapping-delete doc note`.
**Acceptance:** Notification suite green; pager locator scoped; back-leg asserts `next` enabled; the doc note is present.
---
### Task 7: Verification + residue check
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Blocked by:** Tasks 06.
**Files:** none (verification; may touch the plan's `.tasks.json`).
**Step 1 — full build:** `dotnet build src/ZB.MOM.WW.ScadaBridge.CLI` AND `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` — clean under `TreatWarningsAsErrors`.
**Step 2 — full suite vs live cluster:** run the entire Playwright project. Expected: **0 failed**; the new round-trip + alarm-override facts pass; skips logged (the known SmtpEdit no-op). Capture the tally.
**Step 3 — residue scan (must be zero):** `template list` → no `zztest-tmpl-*`; `instance list` → no `zztest-*`; `instance alarm-override list --instance-id <fixture InstanceId>` is N/A post-suite (fixture torn down), but confirm no orphaned override by checking the suite left the fixture instance clean during the run; direct-SQL markers (`AuditLog`/`SiteCalls`/`Notifications` `playwright-test/`/`zztest-notif-*`) → 0; `site-a` left as found.
**Step 4 — app-diff guard (this effort DOES change `src/`):** `git diff --name-only <plan-base>..HEAD | grep '^src/'` must list **only** `src/ZB.MOM.WW.ScadaBridge.CLI/...` (Part A) and `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (Part B). Any other `src/` file changed is a defect — investigate. Everything else is under `tests/` or `docs/plans/`.
**Step 5 — mark complete:** update `…2026-06-07-template-alarm-cli-and-override-coverage.md.tasks.json``completed`; commit `docs(plans): mark template-alarm/override plan tasks complete`.
**Acceptance:** full suite 0-failed; zero residue; clean build; `src/` changes limited to the CLI + the one InstanceConfigure.razor hook addition; `site-a` + fixture left as found.
---
## Scope guard (YAGNI)
No new server command/DTO (Part A rides the existing `AddTemplateAlarmCommand`). No HiLo per-level priority/deadband/message flags (raw `--trigger-config` remains the escape hatch). The codec is NOT moved (CLI-side mirror + round-trip verification). The `data-test` hooks are additive-only. No new fixtures (extend `InstanceConfigureFixture`). Part C is surgical — no suite-wide `WaitForLoadState` refactor, no blind `plant-a` rewrite.
## Success criteria
`template alarm add` accepts typed setpoint flags (round-trip-verified); `InstanceConfigure`'s alarm-override set-priority→badge→clear flow has functional coverage asserting persistence via CLI read-back; the cheap hygiene items are applied; full suite green with logged skips; zero residue; `src/` touched only in the CLI + the single InstanceConfigure hook commit; `site-a` and the shared fixture instance left as found.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-06-07-template-alarm-cli-and-override-coverage.md",
"lastUpdated": "2026-06-07T00:00:00Z",
"nativeTaskIdBase": 125,
"status": "completed",
"tasks": [
{"id": 0, "nativeId": 125, "subject": "Task 0: CLI AlarmTriggerConfigJson + typed flags on template alarm add", "status": "completed"},
{"id": 1, "nativeId": 126, "subject": "Task 1: CliRunner.AddAlarmAsync + DeleteInstanceAlarmOverrideAsync + round-trip test", "status": "completed", "blockedBy": [0]},
{"id": 2, "nativeId": 127, "subject": "Task 2: data-test hooks on InstanceConfigure alarm section + docker rebuild", "status": "completed", "blockedBy": [1]},
{"id": 3, "nativeId": 128, "subject": "Task 3: fixture alarm in InstanceConfigureFixture", "status": "completed", "blockedBy": [1, 2]},
{"id": 4, "nativeId": 129, "subject": "Task 4: AlarmOverride_SetPriority_ThenClear_RoundTrips test", "status": "completed", "blockedBy": [2, 3]},
{"id": 5, "nativeId": 130, "subject": "Task 5: SiteCalls hygiene (plant-a surgical + pager scope + next re-assert)", "status": "completed"},
{"id": 6, "nativeId": 131, "subject": "Task 6: Notification hygiene + DeleteRoleMappingAsync doc note", "status": "completed"},
{"id": 7, "nativeId": 132, "subject": "Task 7: Verification + residue check", "status": "completed", "blockedBy": [0, 1, 2, 3, 4, 5, 6]}
]
}
@@ -0,0 +1,83 @@
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>
/// Serializes typed alarm-setpoint CLI flags into the trigger-config JSON the
/// server expects. Key names MUST stay in lockstep with the canonical codec at
/// src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs
/// (that codec is internal to CentralUI, so this is a deliberate CLI-side mirror;
/// the round-trip test verifies the JSON against the live server — the real contract).
/// </summary>
internal static class AlarmTriggerConfigJson
{
/// <summary>
/// Builds the trigger-config JSON for <paramref name="triggerType"/> from the typed
/// flags, or returns null when none are supplied (so the alarm is created without a
/// trigger config). Unknown/blank trigger types yield null.
/// </summary>
internal static string? Build(
string triggerType, string? attribute,
string? matchValue, bool notEquals,
double? min, double? max,
double? thresholdPerSecond, double? windowSeconds, string? direction,
double? loLo, double? lo, double? hi, double? hiHi,
string? expression)
{
var type = triggerType?.Trim();
var anyTyped = attribute is not null || matchValue is not null || notEquals
|| min.HasValue || max.HasValue || thresholdPerSecond.HasValue || windowSeconds.HasValue
|| direction is not null || loLo.HasValue || lo.HasValue || hi.HasValue || hiHi.HasValue
|| expression is not null;
if (!anyTyped) return null;
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
if (!string.Equals(type, "Expression", StringComparison.OrdinalIgnoreCase))
w.WriteString("attributeName", attribute ?? "");
switch (type?.ToLowerInvariant())
{
case "valuematch":
var mv = matchValue ?? "";
if (notEquals) mv = "!=" + mv;
w.WriteString("matchValue", mv);
break;
case "rangeviolation":
if (min.HasValue) w.WriteNumber("min", min.Value);
if (max.HasValue) w.WriteNumber("max", max.Value);
break;
case "rateofchange":
if (thresholdPerSecond.HasValue) w.WriteNumber("thresholdPerSecond", thresholdPerSecond.Value);
if (windowSeconds.HasValue) w.WriteNumber("windowSeconds", windowSeconds.Value);
w.WriteString("direction", NormalizeDirection(direction));
break;
case "hilo":
// Only the four setpoints are exposed as flags. The codec also accepts
// per-setpoint priorities/deadbands/messages — intentionally omitted here;
// use raw --trigger-config for those (see the YAGNI scope guard in the plan).
if (loLo.HasValue) w.WriteNumber("loLo", loLo.Value);
if (lo.HasValue) w.WriteNumber("lo", lo.Value);
if (hi.HasValue) w.WriteNumber("hi", hi.Value);
if (hiHi.HasValue) w.WriteNumber("hiHi", hiHi.Value);
break;
case "expression":
w.WriteString("expression", expression ?? "");
break;
}
w.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
// Mirrors AlarmTriggerConfigCodec.NormalizeDirection.
private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => "rising",
"falling" or "down" or "negative" => "falling",
_ => "either",
};
}
@@ -226,6 +226,22 @@ public static class TemplateCommands
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false;
// Typed setpoint flags (alternative to raw --trigger-config; raw wins when both supplied).
var attributeOption = new Option<string?>("--attribute") { Description = "Attribute name the trigger watches (all trigger types except Expression)" };
var matchValueOption = new Option<string?>("--match-value") { Description = "ValueMatch: value to compare against" };
var notEqualsOption = new Option<bool>("--not-equals") { Description = "ValueMatch: match when the value is NOT equal (emits !=)" };
notEqualsOption.DefaultValueFactory = _ => false;
var minOption = new Option<double?>("--min") { Description = "RangeViolation: minimum allowed value" };
var maxOption = new Option<double?>("--max") { Description = "RangeViolation: maximum allowed value" };
var thresholdOption = new Option<double?>("--threshold-per-second") { Description = "RateOfChange: rate threshold per second" };
var windowOption = new Option<double?>("--window-seconds") { Description = "RateOfChange: sliding window in seconds" };
var directionOption = new Option<string?>("--direction") { Description = "RateOfChange: direction (rising|falling|either)" };
var loLoOption = new Option<double?>("--lolo") { Description = "HiLo: low-low setpoint" };
var loOption = new Option<double?>("--lo") { Description = "HiLo: low setpoint" };
var hiOption = new Option<double?>("--hi") { Description = "HiLo: high setpoint" };
var hiHiOption = new Option<double?>("--hihi") { Description = "HiLo: high-high setpoint" };
var expressionOption = new Option<string?>("--expression") { Description = "Expression: boolean trigger expression" };
var addCmd = new Command("add") { Description = "Add an alarm to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(nameOption);
@@ -234,17 +250,41 @@ public static class TemplateCommands
addCmd.Add(descOption);
addCmd.Add(triggerConfigOption);
addCmd.Add(lockedOption);
addCmd.Add(attributeOption);
addCmd.Add(matchValueOption);
addCmd.Add(notEqualsOption);
addCmd.Add(minOption);
addCmd.Add(maxOption);
addCmd.Add(thresholdOption);
addCmd.Add(windowOption);
addCmd.Add(directionOption);
addCmd.Add(loLoOption);
addCmd.Add(loOption);
addCmd.Add(hiOption);
addCmd.Add(hiHiOption);
addCmd.Add(expressionOption);
addCmd.SetAction(async (ParseResult result) =>
{
var triggerType = result.GetValue(triggerTypeOption)!;
var rawConfig = result.GetValue(triggerConfigOption);
var triggerConfig = rawConfig ?? AlarmTriggerConfigJson.Build(
triggerType,
result.GetValue(attributeOption),
result.GetValue(matchValueOption), result.GetValue(notEqualsOption),
result.GetValue(minOption), result.GetValue(maxOption),
result.GetValue(thresholdOption), result.GetValue(windowOption), result.GetValue(directionOption),
result.GetValue(loLoOption), result.GetValue(loOption), result.GetValue(hiOption), result.GetValue(hiHiOption),
result.GetValue(expressionOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAlarmCommand(
result.GetValue(templateIdOption),
result.GetValue(nameOption)!,
result.GetValue(triggerTypeOption)!,
triggerType,
result.GetValue(priorityOption)!,
result.GetValue(descOption),
result.GetValue(triggerConfigOption),
triggerConfig,
result.GetValue(lockedOption)));
});
group.Add(addCmd);
@@ -245,7 +245,7 @@
<tbody>
@foreach (var alarm in _overridableAlarms)
{
<tr>
<tr data-test="alarm-override-row-@alarm.Name">
<td class="small">@alarm.Name</td>
<td>
<span class="badge bg-light text-dark border">@alarm.TriggerType</span>
@@ -257,7 +257,7 @@
<td class="small">
@if (HasOverride(alarm.Name))
{
<span class="badge bg-warning text-dark me-1" title="Override is set">●</span>
<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>
<span class="text-muted">@OverrideSummary(alarm.Name)</span>
}
else
@@ -267,11 +267,13 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm me-1"
data-test="alarm-edit-btn"
@onclick="() => BeginEditOverride(alarm)"
disabled="@_saving">Edit</button>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
data-test="alarm-clear-btn"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
@@ -318,6 +320,7 @@
Priority override
</label>
<input type="number" min="0" max="1000" class="form-control form-control-sm"
data-test="alarm-priority-input"
placeholder="@_editingAlarm.PriorityLevel"
@bind="_editingPriorityText" @bind:event="oninput" />
</div>
@@ -339,7 +342,7 @@
</div>
<div>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
<button class="btn btn-success btn-sm" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
</div>
</div>
</div>
@@ -100,6 +100,34 @@ public static partial class CliRunner
await RunAsync([.. args]);
}
/// <summary>
/// Adds an alarm to a template via <c>template alarm add</c> (using the typed setpoint
/// flags) and returns its new <c>id</c>. Throws on failure.
/// </summary>
public static async Task<int> AddAlarmAsync(
int templateId, string name, string triggerType = "HiLo", int priority = 500,
string? attribute = null, double? hi = null, double? hiHi = null,
double? lo = null, double? loLo = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List<string>
{
"template", "alarm", "add",
"--template-id", templateId.ToString(inv),
"--name", name,
"--trigger-type", triggerType,
"--priority", priority.ToString(inv),
};
if (attribute is not null) { args.Add("--attribute"); args.Add(attribute); }
if (hi.HasValue) { args.Add("--hi"); args.Add(hi.Value.ToString(inv)); }
if (hiHi.HasValue) { args.Add("--hihi"); args.Add(hiHi.Value.ToString(inv)); }
if (lo.HasValue) { args.Add("--lo"); args.Add(lo.Value.ToString(inv)); }
if (loLo.HasValue) { args.Add("--lolo"); args.Add(loLo.Value.ToString(inv)); }
using var doc = await RunJsonAsync([.. args]);
return RequireId(doc, "template alarm add");
}
/// <summary>
/// Creates an area under a site via <c>site area create</c> and returns its
/// new <c>id</c>.
@@ -398,7 +426,8 @@ public static partial class CliRunner
/// <summary>
/// Reads an instance's full configuration via <c>instance get</c>; the returned document exposes
/// <c>connectionBindings</c>, <c>attributeOverrides</c>, and <c>areaId</c> for persistence read-back.
/// <c>connectionBindings</c>, <c>attributeOverrides</c>, <c>alarmOverrides</c>, and <c>areaId</c>
/// for persistence read-back.
/// </summary>
/// <remarks>
/// This is the only helper that hands back a live <see cref="JsonDocument"/> (the rest return
@@ -543,6 +572,14 @@ public static partial class CliRunner
/// require changing <see cref="BestEffortAsync"/>'s signature or adding an overload.
/// The inline try/catch is kept here deliberately — same pattern as
/// <see cref="DeleteAreaAsync"/>.
///
/// <para>
/// Maintenance note: this is a three-token CLI group (<c>security role-mapping</c>) so
/// it cannot use the two-token <see cref="BestEffortAsync"/>. If the inline teardown
/// idiom changes (e.g. a new overload of <see cref="BestEffortAsync"/> is added),
/// update all three-token deletes together:
/// <see cref="DeleteAreaAsync"/>, <see cref="DeleteInstanceAlarmOverrideAsync"/>, and this method.
/// </para>
/// </remarks>
public static async Task DeleteRoleMappingAsync(int id)
{
@@ -558,6 +595,18 @@ public static partial class CliRunner
}
}
/// <summary>Best-effort delete of an instance alarm override (teardown). Never throws.</summary>
public static async Task DeleteInstanceAlarmOverrideAsync(int instanceId, string alarmCanonicalName)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
try
{
await RunAsync("instance", "alarm-override", "delete",
"--instance-id", instanceId.ToString(inv), "--alarm", alarmCanonicalName);
}
catch { /* best-effort teardown — never mask the test's own failure. */ }
}
/// <summary>
/// Exports a Transport bundle scoped to a single template via
/// <c>bundle export</c>.
@@ -188,4 +188,23 @@ public class CliRunnerHelpersTests
}
finally { await CliRunner.DeleteRoleMappingAsync(id); }
}
/// <summary>
/// Exercises the typed HiLo setpoint flags end-to-end: a template alarm added via
/// <see cref="CliRunner.AddAlarmAsync"/> with <c>--hi</c>/<c>--hihi</c> returns a
/// positive id, confirming the server accepted the serialized trigger-config JSON.
/// </summary>
[SkippableFact]
public async Task AddAlarmWithTypedFlags_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var id = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("tmpl"));
try
{
await CliRunner.AddAttributeAsync(id, "Value", "Double");
var alarmId = await CliRunner.AddAlarmAsync(id, "HiHi", "HiLo", 500, attribute: "Value", hi: 80, hiHi: 95);
Assert.True(alarmId > 0); // server accepted the serialized trigger-config JSON
}
finally { await CliRunner.DeleteTemplateAsync(id); }
}
}
@@ -36,6 +36,9 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime
/// <summary>The single bindable/overridable attribute name on the fixture template.</summary>
public string AttributeName => "Value";
/// <summary>The single non-locked alarm on the fixture template (for the override test).</summary>
public string AlarmName => "HiHi";
/// <summary>The fixture data-connection name (for locating it in the bindings UI dropdown).</summary>
public string ConnectionName { get; private set; } = string.Empty;
@@ -58,6 +61,8 @@ public sealed class InstanceConfigureFixture : IAsyncLifetime
// Bindings panel shows "No data-sourced attributes" and binding tests cannot run.
// See the class-level XML doc for the full analysis.
await CliRunner.AddAttributeAsync(TemplateId, AttributeName, "Double", dataSourceReference: AttributeName);
await CliRunner.AddAlarmAsync(TemplateId, AlarmName, "HiLo", priority: 500,
attribute: AttributeName, hi: 80, hiHi: 95);
ConnectionName = CliRunner.UniqueName("conn");
ConnectionId = await CliRunner.CreateDataConnectionAsync(SiteAId, ConnectionName);
AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("cfgarea"));
@@ -166,5 +166,82 @@ public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixt
await Assertions.Expect(errorAlert).ToContainTextAsync("not found");
}
// TODO(wave-N): alarm-override UI coverage — needs a template-with-alarm fixture (template alarms are not CLI-provisionable today).
/// <summary>
/// Alarm-override round-trip on the InstanceConfigure page's <b>Alarm Overrides</b> card: Edit the
/// fixture's non-locked HiLo alarm (<c>_cfg.AlarmName</c> = "HiHi"), set a priority override, Save →
/// one toast + an "overridden" badge, then verify the <c>InstanceAlarmOverride</c> actually persisted
/// via a CLI <c>instance get</c> read-back (not just the toast/badge), then Clear → badge gone +
/// override removed (re-verified via read-back).
///
/// <para>
/// Read-back path: the <c>instance get</c> document surfaces an <c>alarmOverrides</c> array whose
/// elements are <c>{ id, instanceId, alarmCanonicalName, priorityLevelOverride }</c> (camelCase,
/// empirically verified against the dev cluster — same instance-document path the
/// <see cref="SaveOverride_RoundTrips"/> test uses for <c>attributeOverrides</c>). For a direct
/// (non-composed) alarm, <c>alarmCanonicalName</c> equals the alarm name.
/// </para>
///
/// <para>
/// Setting only the PRIORITY field is the reliable "create override" delta — a Save with no
/// config-diff AND empty priority deletes the override instead. The fixture instance is SHARED, so
/// the test clears its own override in-body (badge gone + read-back empty) and again in a
/// <c>finally</c>, leaving the instance override-free as found.
/// </para>
/// </summary>
[SkippableFact]
public async Task AlarmOverride_SetPriority_ThenClear_RoundTrips()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The Alarm Overrides card renders one row per non-locked template alarm; web-first wait
// for the fixture alarm's row so we never race the post-load re-render. No override yet.
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
// Edit → set a priority override → Save. FillAsync fires the input event, so the priority
// input's @bind:event="oninput" commits before the Save click (no extra change dispatch).
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var priorityInput = page.Locator("[data-test='alarm-priority-input']");
await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await priorityInput.FillAsync("750");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
// Verify the InstanceAlarmOverride persisted via CLI read-back (not just the toast/badge).
using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
{
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.Contains(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName
&& o.GetProperty("priorityLevelOverride").GetInt32() == 750);
}
// Clear is immediate (no confirm): the badge disappears and the override is removed.
await row.Locator("[data-test='alarm-clear-btn']").ClickAsync();
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
{
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
}
}
finally
{
// Belt-and-braces: leave the shared fixture instance override-free even if an assertion
// above threw after the Save (best-effort; never masks the test's own failure).
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
}
@@ -415,7 +415,7 @@ public class NotificationActionTests
// placeholder that shares that class renders only while `_notifications == null`.
var prev = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')");
var next = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')");
var indicator = page.Locator("span.text-muted.small");
var indicator = page.Locator(".d-flex.justify-content-between.align-items-center span.text-muted.small");
// ── Page 1 ── (count first — it waits out the fetch — then indicator + buttons).
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
@@ -435,6 +435,7 @@ public class NotificationActionTests
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
await Assertions.Expect(prev).ToBeDisabledAsync();
await Assertions.Expect(next).ToBeEnabledAsync();
}
finally
{
@@ -122,11 +122,11 @@ public class SiteCallsPageTests
// One ApiOutbound row, one DbOutbound row — distinct Targets.
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api",
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
sourceSite: "site-a", status: "Delivered", retryCount: 0,
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
sourceSite: "site-a", status: "Delivered", retryCount: 0,
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
var page = await _fixture.NewAuthenticatedPageAsync();
@@ -176,7 +176,7 @@ public class SiteCallsPageTests
{
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint",
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
sourceSite: "site-a", status: "Delivered", retryCount: 0,
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
var page = await _fixture.NewAuthenticatedPageAsync();
@@ -226,12 +226,12 @@ public class SiteCallsPageTests
// actionable from central).
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
sourceSite: "plant-a", status: "Parked", retryCount: 3,
sourceSite: "site-a", status: "Parked", retryCount: 3,
lastError: "HTTP 503 from ERP", httpStatus: 503,
createdAtUtc: now, updatedAtUtc: now);
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed",
sourceSite: "plant-a", status: "Failed", retryCount: 1,
sourceSite: "site-a", status: "Failed", retryCount: 1,
lastError: "constraint violation",
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
@@ -546,10 +546,13 @@ public class SiteCallsPageTests
await SetSearchKeywordAsync(page, sharedTarget);
await page.ClickAsync("[data-test='site-calls-query']");
// The pager indicator span (`Page {N} · {rows} rows`). It is the only
// text-muted small span in the table footer, so a scoped GetByText
// regex is unambiguous.
var pageIndicator = page.Locator("span.text-muted.small");
// The pager indicator span (`Page {N} · {rows} rows`). Scope the
// locator to the pager wrapper div
// (`.d-flex.justify-content-between.align-items-center`) so a future
// second `span.text-muted.small` elsewhere on the page can't make
// this match ambiguous under strict mode.
var pageIndicator = page.Locator(
".d-flex.justify-content-between.align-items-center span.text-muted.small");
// ── Page 1: full page (50 rows). Assert COUNT first (waits for the
// fetch), then the indicator and the button states. ──
@@ -570,6 +573,9 @@ public class SiteCallsPageTests
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeDisabledAsync();
// Page 1 is full again on the back-leg, so Next must re-enable —
// catches a regression that left Next disabled after a page-back.
await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeEnabledAsync();
}
finally
{