Compare commits
11 Commits
fdea9e0bde
...
68f911e634
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f911e634 | |||
| 5bc8dbad31 | |||
| d3adf8c2e4 | |||
| f78086334f | |||
| c3d7d8a6a4 | |||
| bc8960779b | |||
| c84eb5aeef | |||
| f0b144ebda | |||
| bbc3804d07 | |||
| 9d7e69056a | |||
| 475bfadacd |
@@ -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` (0–1000), `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:** #125–132 (plan Task 0–7).
|
||||
|
||||
---
|
||||
|
||||
## 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 0–6.
|
||||
|
||||
**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);
|
||||
|
||||
+6
-3
@@ -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>.
|
||||
|
||||
+19
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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"));
|
||||
|
||||
+78
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
{
|
||||
|
||||
+15
-9
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user