Files
ScadaBridge/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md
T

578 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Playwright Coverage Fill — Wave 4 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:** Close the cross-cutting edge-case gaps (duplicate-name, cancel, empty-state, filter-combination, pagination) on the six already-covered Central UI pages — Sites, Templates, LDAP mappings, Audit Log, Site Calls, Notification Report — by **extending** their existing test suites, ending green with zero residue.
**Architecture:** Each task adds 12 edge `[SkippableFact]`s to an existing test class (no new test files), driving real behavior against the live 8-node docker cluster, outcome- and selector-grounded against the **actual** page code-behind (the ⚠ validation-behavior protocol). Fixtures/seeders are reused; only three tiny *test-side* helpers are added (2 CLI verbs + 1 seeder generalization). **No app-code changes, no new `data-test` hooks, no `docker/deploy.sh` rebuild** — every edge surface already has a stable selector once the two un-wired "Escape closes" legs are dropped.
**Tech Stack:** xUnit + `Xunit.SkippableFact`, Microsoft.Playwright (remote Chromium `ws://localhost:3000`), Microsoft.Data.SqlClient (direct-SQL seeders), the `scadabridge` CLI. TFM `net10.0`, `Nullable=enable`, `TreatWarningsAsErrors=true`.
---
## Grounding facts (verified during planning — do NOT re-derive)
**Harness conventions (reuse verbatim):**
- All classes are `[Collection("Playwright")]` (serial via `ICollectionFixture<PlaywrightFixture>`); ctor injects `PlaywrightFixture _fixture`.
- Auth: `await _fixture.NewAuthenticatedPageAsync()` → logs in `multi-role`/`password` (has Administrator + Designer + Deployer + Viewer; satisfies every page below). Browser base URL `PlaywrightFixture.BaseUrl` = `http://scadabridge-traefik`; CLI from host `http://localhost:9000`.
- Toast assertion: web-first `page.Locator(".toast")` with `ToHaveCountAsync(1)`.
- Danger confirm: `.modal-footer .btn-danger` (text "Delete"); plain confirm `.modal-footer .btn-primary` (text "Confirm").
- Ephemeral fixture naming: `CliRunner.UniqueName("kind")``zztest-<kind>-<8hex>`; direct-SQL seeders use a per-run marker (`playwright-test/<scenario>/{runId}/…` for audit/site-calls `Target`, `zztest-notif-…-{runId}` for the notification `ListName`).
- **Two skip-gate idioms — match the one already in each file:** `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)` (CLI probe — used by SiteCrudTests, TemplateCrudTests, LdapMappingCrudTests, AuditLogPageTests) **vs.** `Skip.IfNot(await <Seeder>.IsAvailableAsync(), DbUnavailableSkipReason)` (direct-SQL probe — used by SiteCallsPageTests, NotificationActionTests, AuditGridColumnTests). Do not mix.
- Text inputs `@bind` commit on **change/blur, not Enter** — after `FillAsync` into a search box, call `DispatchEventAsync("change")` before clicking Query (the existing `SetSearchKeywordAsync`/`SetSubjectKeywordAsync`/`SetSearchKeywordAsync` helpers already do this). `SelectOptionAsync` fires `change` itself.
- **Prerender-hydration race (Wave-3 gotcha, still live):** the UI uses InteractiveServer WITH prerendering, so an input can be "visible" before its `@bind` handler is wired. For any test that fills a field then acts WITHOUT an intervening server roundtrip, force a confirmed roundtrip first (e.g. assert an Apply/Query result, or a prior validation message) before relying on the committed value. Most Wave-4 tests click Query/Apply (a roundtrip) right after filling, so they are naturally safe; flag in code where they aren't.
**No-residue rule:** best-effort teardown in `finally`/`DisposeAsync`, keyed on `zztest-*` / the run marker. Tests that collide against the real seeded `site-a` persist nothing (the create is rejected). The mutating cancel/edit tests create their own `zztest-*` entity and delete it. Wave ends with the residue scan → zero.
**Cadence constraint:** one shared Playwright browser + one cluster + one build → every cluster-running implementer is serialized. Test tasks are therefore `Parallelizable with: none`; only the two non-cluster prep tasks (0, 1) may be dispatched concurrently with each other. Reviewers (read-only) may still overlap with the next implementer per subagent-driven-development.
### Per-area verified selectors & actual behavior
**Sites** (`/admin/sites`, form `/admin/sites/create` & `/admin/sites/{id}/edit`, single `SiteForm.razor`):
- Identifier input: `label:has-text('Identifier') + input.form-control.form-control-sm` (**`disabled` in edit mode**). Name: `label:has-text('Name') + input.form-control.form-control-sm`. Description: `label:has-text('Description') + input.form-control.form-control-sm`.
- Save: `button.btn.btn-success.btn-sm:has-text('Save')`. Cancel: `button:has-text('Cancel')` (== Back; both call `GoBack()``/admin/sites`, no dirty-check, no persist).
- Error surface: `div.text-danger.small.mt-2` (single element, reused for all errors). `SaveSite()`: blank name → `"Name is required."`; create-mode blank identifier → `"Identifier is required."`; **duplicate identifier (or name) → caught `DbUpdateException` → `_formError = $"Save failed: {ex.Message}"`, stays on `/admin/sites/create`.** Assert `"Save failed"` substring, NOT the literal.
- **No URL validation** (form accepts any string; only >500 chars would error). `Name` is ALSO unique-indexed → keep the dup test's Name distinct from the collision target.
- CLI: `ResolveSiteIdAsync(identifier)` (throws if absent), `DeleteSiteAsync(id)` (best-effort). **No `CreateSiteAsync`** — create via UI.
**Templates** (`/design/templates`, create `/design/templates/create`, edit `/design/templates/{id}`):
- Create Name: `div.mb-3:has(label:has-text('Name')) input.form-control`. Create button: `button.btn.btn-success:has-text('Create')`. Create error: `div.text-danger.small` (inline; stays on `/design/templates/create`). Create Cancel: `button.btn.btn-outline-secondary:has-text('Cancel')``/design/templates`.
- **Duplicate base-template name** → DB unique index (`HasIndex(t=>t.Name).IsUnique().HasFilter("[IsDerived]=0")`), no friendly pre-check in `TemplateService.CreateTemplateAsync` → error lands inline in `div.text-danger.small`. Assert the div is visible/non-empty + URL still `/create`; do NOT assert a literal.
- Edit page attribute modal: trigger `button.btn.btn-primary.btn-sm:has-text('Add Attribute')`; existing-attr edit via row dropdown `button[aria-label="More actions for {attr.Name}"]``button.dropdown-item:has-text('Edit…')`. Modal = `.modal.show.d-block:not(.fade)` (page-local, no `fade`); title `h6.modal-title` = "Add Attribute"/"Edit Attribute". Modal fields: Name `input.form-control @bind=_attrName` (**`readonly` when editing**), Data Type `select.form-select`, Value `input.form-control`. Footer save button text is **"Save" when editing, "Add" when adding** → `.modal.show.d-block:not(.fade) .modal-footer button.btn-success.btn-sm:has-text('Save')`. Attr rows: `table td:has-text('{name}')`.
- **Delete-blocked-by-instances:** header Delete `button.btn.btn-outline-danger.btn-sm:has-text('Delete')` → DialogHost confirm `.modal-footer .btn-danger:has-text('Delete')` (this DialogHost modal HAS `fade`) → server guard fails → **`.toast`** containing `Cannot delete template '{name}': {n} instance(s) reference it (...)`. A **non-deployed** instance referencing the template is sufficient to trigger the block.
- Reuse **`DeploymentFixture`** (`IClassFixture<DeploymentFixture>` + `[Collection("Playwright")]`, exactly as `DeploymentActionTests`): exposes `bool Available`, `int TemplateId`, `int SiteAId`, `int AreaId`, `Task<(int Id,string UniqueName)> CreateInstanceAsync()` (creates, does NOT deploy). CLI: `CreateTemplateAsync`, `DeleteTemplateAsync`, `ListTemplateIdsByNamePrefixAsync`, `AddAttributeAsync`, `DeleteInstanceAsync`.
**LDAP** (`/admin/ldap-mappings`, form `/admin/ldap-mappings/create` & `/{id}/edit`, single `LdapMappingForm.razor`):
- Group input: `label:has-text('LDAP Group Name') + input.form-control.form-control-sm`. Role select: `div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm` (options `""`,`Administrator`,`Designer`,`Deployer`,`Viewer`). Save: `button.btn.btn-success.btn-sm:has-text('Save')`. Cancel: `button.btn.btn-outline-secondary.btn-sm:has-text('Cancel')`.
- Error surface: `div.text-danger.small.mt-2` (single element). `SaveMapping()`: blank group → `"LDAP Group Name is required."` (checked first, returns); blank role → `"Role is required."`. **Duplicate group → DB unique index (`HasIndex(m=>m.LdapGroupName).IsUnique()`), no pre-check → caught → `"Save failed: {ex.Message}"`.** Assert `"Save failed"` substring.
- Rows: `page.Locator("tr", new(){ HasText = group })`. Mapping delete = list kebab `button.dropdown-item.text-danger` (**no confirm dialog**).
- CLI verbs exist (`security role-mapping create|list|delete`) but no typed helper today — **Task 0 adds them.**
**Audit Log** (`/audit/log``AuditLogPage`, filter bar + results grid + drill-down drawer):
- Filter bar (`[data-test='audit-filter-bar']`): channel `[data-test='filter-channel-select']` (`SelectOptionAsync("ApiOutbound"|"ApiInbound"|"DbOutbound"|…)`, `""`=all), site multi `[data-test='filter-site-ms']`, time-range `#audit-time-range` (`Last5Minutes`/`LastHour`(default)/`Last24Hours`/`Custom`), custom `#audit-from`/`#audit-to` (only when `Custom`), execution-id text `#audit-execution-id` (exact GUID), target text `#audit-target` (contains), **Apply `[data-test='filter-apply']`**, Clear `[data-test='filter-clear']` (resets model only — does NOT re-query).
- **Grid does NOT auto-load on bare visit** — click Apply, or arrive with a drill-in querystring. Grid `[data-test='audit-results-grid']`; rows `[data-test='grid-row-{EventId}']` (and `[data-test^='grid-row-']` for counting); empty-state cell literal **`No audit events match the current filter.`** (no `data-test`). Page size **100** (no test override). Page indicator text `Page N · M rows`.
- Drawer: open by clicking a row; container `[data-test='audit-drilldown-drawer']`; backdrop `[data-test='drawer-backdrop']` (click closes); **X `[data-test='drawer-close']`**; footer `[data-test='drawer-close-footer']` (closes). **No Escape handler** (the keydown handler lives only on the sibling `ExecutionDetailModal`). cURL button `[data-test='copy-as-curl']` renders **iff `channel ∈ {ApiOutbound, ApiInbound}`** (`IsApiChannel`); a `DbOutbound`/`Notification` row's drawer omits it.
- `AuditDataSeeder` (table **`AuditLog`**): `InsertAuditEventAsync(eventId, occurredAtUtc, channel, kind, status, sourceSiteId?, target?, actor?, correlationId?, executionId?, parentExecutionId?, httpStatus?, …)`, `DeleteByTargetPrefixAsync(targetPrefix)`, `IsAvailableAsync()`. `DbUnavailableSkipReason` const lives in `AuditGridColumnTests.cs` — but **AuditLogPageTests uses `ClusterAvailability.SkipReason`** (match the file). Default time-range `LastHour`, so seed rows at "now".
**Site Calls** (`/site-calls/report``SiteCallsReport`):
- Filters: status `#sc-status` (`""`=All, `Submitted`/`Forwarded`/`Attempted`/`Delivered`/`Parked`/`Failed`/`Discarded`), channel `#sc-channel` (`ApiOutbound`/`DbOutbound`), site `#sc-site`, node `#sc-node` (exact), from/to `#sc-from`/`#sc-to`, target keyword `#sc-search` (**exact match**; use `SetSearchKeywordAsync` to commit), stuck-only `#sc-stuck-only`. **Query button `[data-test='site-calls-query']`** (resets to page 1). **Auto-loads on visit** (`OnInitializedAsync → RefreshAll`), so narrow by the run marker to isolate seeded rows.
- Grid: rows `tbody tr`; status badge `span.badge:has-text('{Status}')`. **Empty state literals** `No site calls` / `No cached calls match the current filters.` (no `data-test`). Page size **50**.
- **Pagination (confirmed wired):** prev `[data-test='site-calls-prev']` (text "Previous", disabled on page 1), next `[data-test='site-calls-next']` (text "Next", **enabled only when the page is exactly full = 50 rows**). Keyset order `CreatedAtUtc DESC, TrackedOperationId DESC`. Page indicator `Page {N} · {rows} rows`. Pager footer renders only when rows exist.
- `SiteCallDataSeeder` (table **`SiteCalls`**): `InsertSiteCallAsync(trackedOperationId, channel, target, sourceSite, status, retryCount, createdAtUtc, updatedAtUtc, lastError?, httpStatus?, terminalAtUtc?)`, `DeleteByTargetPrefixAsync(prefix)`, `IsAvailableAsync()`, `ConnectionString`. **`DbUnavailableSkipReason`** const is in `SiteCallsPageTests.cs`. **`FilterPermittedAsync` drops rows whose `SourceSite` is outside the user's permitted set → seed with `sourceSite:"site-a"`.** `Target` is exact-match, so **51 rows sharing ONE identical target value** are all selected by one `#sc-search` keyword (the deterministic pagination isolator). Stagger `createdAtUtc` for strict keyset order.
**Notification Report** (`/notifications/report``NotificationReport`, single file, `RequireDeployment`):
- Filters: status `#no-status` (`""`=All,`Pending`/`Retrying`/`Delivered`/`Parked`/`Discarded`), type `#no-type` (`Email`), site `#no-site`, list `#no-list` (exact `ListName`), node `#no-node` / `[data-test='notif-filter-node']`, from/to `#no-from`/`#no-to`, subject keyword `#no-search` (substring; use `SetSubjectKeywordAsync`), **stuck-only `#no-stuck-only`** (label "Stuck only"). **Query `button.btn-primary:has-text('Query')`** (no `data-test`; resets to page 1). Clear `button.btn-outline-secondary.btn-sm:has-text('Clear')`. **Auto-loads on visit.** Page size **50**.
- Grid rows `tbody tr`. Stuck badge `span.badge.bg-warning.text-dark:has-text('Stuck')`; stuck row highlight `tr.table-warning`. Empty-state literals `No notifications` / `No notifications match the current filters.`
- **Detail modal: open by DOUBLE-CLICK on a non-Actions row cell** (`@ondblclick`; Actions cell stops propagation). Container `.modal.show.d-block`; title `.modal-title:has-text('Notification Detail')`; **X `button.btn-close[aria-label="Close"]`**; footer `.modal-footer button.btn-outline-secondary:has-text('Close')`; **backdrop click closes** (outer `.modal` `@onclick`, inner dialog stops propagation). **No Escape handler.**
- Pager renders **only when `_totalCount > 50`**: prev `button.btn-outline-secondary.btn-sm:has-text('Previous')` (disabled page 1), next `…:has-text('Next')` (disabled when current page < 50 rows). Indicator `Page {N} of {pages} · {total} total`.
- `NotificationDataSeeder` (table **`Notifications`**): today only `InsertParkedNotificationAsync(...)` (hard-codes `Status="Parked"`, `CreatedAt=now`). **`IsStuck` is derived: `Status ∈ {Pending,Retrying} && CreatedAt < now 10min`** → a Parked@now row is NEVER stuck. **Task 1 generalizes the seeder** so a back-dated `Retrying` row can be seeded for the positive stuck test. `DeleteByMarkerAsync(listName)`, `IsAvailableAsync()`, `DbUnavailableSkipReason` const in `NotificationActionTests.cs`.
### Scope changes vs. the design (and why)
- **DROP** Sites "invalid Akka/gRPC URL" — no URL validation exists in `SiteForm`.
- **DROP** Audit Log pagination — page size is 100 with no test-facing override; a 101-row seed is disproportionate, and the pagination *pattern* is covered by Site Calls (keyset) and Notification (page-number). Documented, not silent.
- **DROP** the "Escape closes" leg on both the Audit drawer and the Notification detail modal — neither is wired in the app. Replace with the X / backdrop / footer-Close paths that ARE wired (no app change this wave).
- Everything else in the design's Wave-4 list is kept and grounded above.
---
## Task 0: CLI role-mapping helpers + round-trip test
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs`
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs`
**Why:** the LDAP duplicate-group test (Task 5) needs to seed a pre-existing mapping and clean it up deterministically by id. The CLI verbs exist (`security role-mapping create|list|delete`); add typed wrappers mirroring the existing `Create…/Delete…` split (provision throws, teardown best-effort).
**Step 1 — add helpers to `CliRunner.Helpers.cs`** (mirror `CreateExternalSystemAsync`/`DeleteExternalSystemAsync` style; reuse the existing private `RequireId(JsonDocument)` and `BestEffortAsync(group, verb, id)`):
```csharp
/// <summary>Creates an LDAP→role mapping; returns its id. Throws on failure.</summary>
public static async Task<int> CreateRoleMappingAsync(string ldapGroup, string role = "Designer")
{
using var doc = await RunJsonAsync("security", "role-mapping", "create",
"--ldap-group", ldapGroup, "--role", role);
return RequireId(doc);
}
/// <summary>Best-effort delete of an LDAP→role mapping (teardown).</summary>
public static Task DeleteRoleMappingAsync(int id)
=> BestEffortAsync("security role-mapping", "delete", id, group2: "role-mapping");
```
If `BestEffortAsync`'s signature doesn't accommodate the two-token group (`security role-mapping`), follow whatever the existing role-mapping teardown in `LdapMappingCrudTests` already does (`CliRunner.RunAsync("security","role-mapping","delete","--id",id)`) and wrap it in a try/catch that swallows. Match the file's existing idiom exactly — read the surrounding helpers before writing.
**Step 2 — add a round-trip test to `CliRunnerHelpersTests.cs`** (mirror `CreateThenDeleteApiMethod_RoundTrips`):
```csharp
[SkippableFact]
public async Task CreateThenDeleteRoleMapping_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var group = CliRunner.UniqueName("grp");
var id = await CliRunner.CreateRoleMappingAsync(group, "Designer");
try
{
Assert.True(id > 0);
using var list = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
Assert.Contains(list.RootElement.EnumerateArray(),
e => e.TryGetProperty("ldapGroupName", out var n) && n.GetString() == group);
}
finally { await CliRunner.DeleteRoleMappingAsync(id); }
}
```
**Step 3 — run:** `dotnet test --filter CreateThenDeleteRoleMapping_RoundTrips` (from the test project dir). Expected: PASS (or SKIPPED if cluster down — then it cannot be validated; note that and proceed, the downstream Task 5 will exercise it).
**Step 4 — commit:** `test(playwright): add CLI role-mapping create/delete helpers (Wave 4 prep)`.
**Acceptance:** helpers compile under `TreatWarningsAsErrors`; round-trip passes against the live cluster; `CreateRoleMappingAsync` returns a positive id; teardown leaves no `zztest-grp-*` mapping.
---
## Task 1: Generalize NotificationDataSeeder for non-Parked / back-dated rows
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs`
**Why:** the positive "Stuck only" test (Task 11) needs a row that is actually stuck — `Status ∈ {Pending,Retrying}` AND `CreatedAt < now 10min`. The current `InsertParkedNotificationAsync` hard-codes `Status="Parked"`, `CreatedAt=now` (never stuck). Add a general insert and refactor the existing method to delegate (DRY; no behavior change for existing callers).
**Step 1 — read** `NotificationDataSeeder.cs` fully to learn the exact INSERT column list and the existing parameter names.
**Step 2 — add a general method** and make `InsertParkedNotificationAsync` call it. Add `status` and `createdAt` parameters (default to the current Parked@now behavior):
```csharp
/// <summary>Inserts one notification row with explicit status/created-at (for stuck/age edge cases).</summary>
public static async Task InsertNotificationAsync(
Guid notificationId, string listNameMarker, string subject,
string status, DateTimeOffset createdAt,
int retryCount = 0, string? lastError = "SMTP 451 transient failure (seeded)",
CancellationToken ct = default)
{
// identical body to the existing InsertParkedNotificationAsync INSERT, but with
// @status = status and @createdAt = createdAt (and @siteEnqueuedAt = createdAt).
// Keep every other NOT-NULL column exactly as the existing method sets it.
}
public static Task InsertParkedNotificationAsync(
Guid notificationId, string listNameMarker, string subject,
int retryCount = 0, string? lastError = "SMTP 451 transient failure (seeded)",
CancellationToken ct = default)
=> InsertNotificationAsync(notificationId, listNameMarker, subject,
status: "Parked", createdAt: DateTimeOffset.UtcNow, retryCount, lastError, ct);
```
Preserve the existing method's public signature so Tasks 9/10's existing callers and the current tests are untouched. Confirm the `Notifications` INSERT does NOT require a terminal/`TerminalAtUtc` column for a non-terminal status (the existing INSERT column list is the source of truth — do not add columns).
**Step 3 — build:** `dotnet build` (must be clean under `TreatWarningsAsErrors`). No standalone test — this is a test-helper refactor exercised by Tasks 10/11.
**Step 4 — commit:** `test(playwright): generalize NotificationDataSeeder for status/created-at (Wave 4 prep)`.
**Acceptance:** builds clean; `InsertParkedNotificationAsync` behavior is byte-identical to before (delegates with Parked/now); the new method allows `status:"Retrying", createdAt: now-15min`.
---
## Task 2: Sites edge — duplicate identifier + cancel-from-edit
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (cluster/browser)
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs`
**Existing coverage to NOT duplicate:** create-page Cancel→list, Save-without-name→`.text-danger`, Back→list, full create/edit/delete round-trip. The new value is **duplicate-identifier** and **cancel-from-EDIT-with-a-changed-field**.
**Step 1 — `Create_DuplicateIdentifier_ShowsSaveFailedError`** (`[SkippableFact]`, `ClusterAvailability` gate). Collide against the always-present seeded `site-a` identifier (the failed create persists nothing → zero residue):
- `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);`
- Confirm the collision target exists: `await CliRunner.ResolveSiteIdAsync("site-a")` (it throws if absent — let that surface; site-a is a core cluster seed).
- `var page = await _fixture.NewAuthenticatedPageAsync();``GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create")`.
- Fill Identifier = `"site-a"` (the duplicate), Name = `$"zztest-dup-{Guid.NewGuid():N}"[..16]` (**distinct** so the unique-Name index can't be the thing that trips). Leave node addresses blank.
- Click Save (`button.btn.btn-success.btn-sm:has-text('Save')`).
- Assert (web-first): `Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed")` AND `Expect(page).ToHaveURLAsync(new Regex("/admin/sites/create"))`. (Do NOT assert the raw DB message.)
- No teardown needed (nothing persisted). Optionally a `finally` safety sweep: best-effort `DeleteSiteAsync(ResolveSiteIdAsync(name))` swallowing the "not found" throw.
**Step 2 — `EditCancel_DiscardsChanges`** (`[SkippableFact]`). Create a `zztest-site` via UI, edit a field, Cancel, verify the original persists:
- Gate; `var hex = Guid.NewGuid().ToString("N")[..8]; var ident = $"zztest-{hex}"; var name = $"zztest-site-{hex}";`
- Create via UI (mirror the round-trip test's create steps: fill Identifier+Name, Save, wait for `/admin/sites`).
- Open edit: locate the site card (`div.card` HasText=name) → its Edit button (`button.btn.btn-outline-primary.btn-sm:has-text('Edit')`); wait for `/{id}/edit` (URL contains `/edit`).
- Change the Description input to `"zztest-CANCELLED-EDIT"`; click `button:has-text('Cancel')`; wait for `/admin/sites` (excludePath `/edit`).
- Re-open the same card's Edit; assert the Description input value is the ORIGINAL (empty): `Expect(descInput).ToHaveValueAsync("")`. (Proves Cancel discarded.)
- `finally`: `try { await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident)); } catch { }`.
**Step 3 — run:** `dotnet test --filter "Create_DuplicateIdentifier_ShowsSaveFailedError|EditCancel_DiscardsChanges"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Sites duplicate-identifier + cancel-from-edit edge cases (Wave 4)`.
**Acceptance:** both pass; site-a untouched; no `zztest-site-*` residue (`site list` clean).
---
## Task 3: Templates edge p1 — duplicate name + create-cancel
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs`
**Existing coverage to NOT duplicate:** create→add-attribute→delete round-trip (happy path).
**Step 1 — `CreateTemplate_DuplicateName_ShowsInlineError`** (`[SkippableFact]`, `ClusterAvailability` gate). Seed an existing base template by name via CLI, then UI-attempt the duplicate:
- `var name = CliRunner.UniqueName("tmpl");`
- `var seededId = await CliRunner.CreateTemplateAsync(name);` (this is the existing-name source).
- try: navigate `/design/templates/create`; fill Name = `name` (the duplicate) via `div.mb-3:has(label:has-text('Name')) input.form-control`; click `button.btn.btn-success:has-text('Create')`.
- Assert: `Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync()` AND not empty (`ToContainTextAsync` of a non-empty match, or assert `.Count >= 1` + text length) AND `Expect(page).ToHaveURLAsync(new Regex("/design/templates/create"))`. (Behavior confirmed: DB unique index, no friendly pre-check → caught into `_formError`. Do NOT assert a literal — capture whatever surfaces.)
- `finally`: `await CliRunner.DeleteTemplateAsync(seededId);` and sweep any `ListTemplateIdsByNamePrefixAsync(name)` leftovers.
> **⚠ validation-behavior protocol:** before finalizing, the implementer confirms the duplicate create does NOT silently succeed-and-navigate. If (contrary to the EF unique index) the cluster allows it and navigates to `/design/templates/{id}`, re-scope this to the **attribute** duplicate instead (which IS friendly-guarded: add attribute `Val`, add `Val` again → modal `div.text-danger.small` reading `Attribute 'Val' already exists on template '{name}'.`). The EF index strongly implies the template-name path works; this is the documented fallback.
**Step 2 — `CreateTemplate_Cancel_ReturnsToListWithoutCreating`** (`[SkippableFact]`):
- `var name = CliRunner.UniqueName("tmpl");`
- Navigate `/design/templates/create`; fill Name = `name`; click `button.btn.btn-outline-secondary:has-text('Cancel')`; wait for `/design/templates` (excludePath `/create`).
- Assert no template with that name exists: `Assert.Empty(await CliRunner.ListTemplateIdsByNamePrefixAsync(name))`.
- No teardown needed (nothing created); defensive sweep in `finally` anyway.
**Step 3 — run:** `dotnet test --filter "CreateTemplate_DuplicateName_ShowsInlineError|CreateTemplate_Cancel_ReturnsToListWithoutCreating"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Templates duplicate-name + create-cancel edge cases (Wave 4)`.
**Acceptance:** both pass; zero `zztest-tmpl-*` residue (`template list` clean).
---
## Task 4: Templates edge p2 — edit attribute + delete-blocked-by-instance
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs`
**Blocked by:** Task 3 (same file — sequential edits).
**Setup:** the delete-blocked test reuses **`DeploymentFixture`**. Add `IClassFixture<DeploymentFixture>` to the class and accept it in the ctor (`public class TemplateCrudTests(PlaywrightFixture pw, DeploymentFixture cluster)` with `[Collection("Playwright")]` + `IClassFixture<DeploymentFixture>` — mirror `DeploymentActionTests`). If the class is currently a non-primary-ctor form, adapt to keep `_fixture` working and add `_cluster`.
**Step 1 — `EditAttribute_PersistsChange`** (`[SkippableFact]`, `ClusterAvailability` gate). Create a template + attribute via CLI, edit the attribute's Value in the UI modal, verify persisted:
- `var name = CliRunner.UniqueName("tmpl"); var id = await CliRunner.CreateTemplateAsync(name); await CliRunner.AddAttributeAsync(id, "Val", "Double");`
- try: navigate `/design/templates/{id}`; open the `Val` row dropdown `button[aria-label="More actions for Val"]``button.dropdown-item:has-text('Edit…')`; assert modal `.modal.show.d-block:not(.fade)` with title "Edit Attribute"; confirm Name input is `readonly`; set the Value input to `"42.5"`; click `.modal.show.d-block:not(.fade) .modal-footer button.btn-success.btn-sm:has-text('Save')`.
- Assert the modal closes and the change persisted: web-first wait for the modal to detach, then assert the attribute row reflects the new value (`table tr:has(td:has-text('Val'))` contains `42.5`), OR re-read via `LoadAsync` round-trip — the simplest deterministic check is to assert the row's value cell text. If the table doesn't render the value column, fall back to a toast/no-error assertion + reload-and-reopen the modal asserting the Value input now reads `42.5`.
- `finally`: `await CliRunner.DeleteTemplateAsync(id);`.
**Step 2 — `DeleteTemplate_WithInstance_IsBlocked`** (`[SkippableFact]`). Use `DeploymentFixture` (a template with an instance referencing it):
- `Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);`
- `var (instId, _) = await _cluster.CreateInstanceAsync();` (creates a non-deployed instance referencing `_cluster.TemplateId` — sufficient to block).
- try: navigate `/design/templates/{_cluster.TemplateId}`; click header Delete `button.btn.btn-outline-danger.btn-sm:has-text('Delete')`; confirm `.modal-footer .btn-danger:has-text('Delete')`.
- Assert: `Expect(page.Locator(".toast")).ToContainTextAsync("instance(s) reference it")` (web-first) — the block message `Cannot delete template '{name}': {n} instance(s) reference it (...)`. Also assert the template still exists: still on the detail page (URL still `/design/templates/{id}`) or `Assert.NotEmpty(await CliRunner.ListTemplateIdsByNamePrefixAsync(_cluster's template name))` if its name is exposed; the URL check is simplest.
- `finally`: `await CliRunner.DeleteInstanceAsync(instId);` (the fixture's `DisposeAsync` sweeps the template + any leftover instances).
**Step 3 — run:** `dotnet test --filter "EditAttribute_PersistsChange|DeleteTemplate_WithInstance_IsBlocked"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Templates edit-attribute + delete-blocked-by-instance edge cases (Wave 4)`.
**Acceptance:** both pass; the `DeploymentFixture` template restored to instance-free; zero `zztest-*` residue.
---
## Task 5: LDAP edge — missing-field + duplicate group
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs`
**Blocked by:** Task 0 (CLI role-mapping helpers).
**Existing coverage to NOT duplicate:** create→edit(role)→delete round-trip.
**Step 1 — `Save_MissingGroupName_ShowsRequiredError`** (`[SkippableFact]`, `ClusterAvailability` gate). The `SaveMapping` handler checks group-blank FIRST:
- Navigate `/admin/ldap-mappings/create`; leave the group input blank; select a role (`SelectOptionAsync` on `div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm``"Designer"`); click Save (`button.btn.btn-success.btn-sm:has-text('Save')`).
- Assert: `Expect(page.Locator("div.text-danger.small.mt-2")).ToHaveTextAsync("LDAP Group Name is required.")` AND URL still `/admin/ldap-mappings/create`.
- (Optional second leg in the same test — `Role is required.`: fill the group with a `zztest-grp` value, clear the role back to `""` via `SelectOptionAsync("")`, Save → assert the div now reads `"Role is required."`. Keep the prerender-race in mind: the prior Save already proved the circuit is live, so the second fill+Save is safe.)
- No persistence (both validations return early) → no teardown.
**Step 2 — `Create_DuplicateGroup_ShowsSaveFailedError`** (`[SkippableFact]`). CLI-seed a mapping, UI-attempt the duplicate:
- `var group = CliRunner.UniqueName("grp"); var seededId = await CliRunner.CreateRoleMappingAsync(group, "Designer");`
- try: navigate `/admin/ldap-mappings/create`; fill group input = `group` (exact same string); select role `"Viewer"`; click Save.
- Assert: `Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed")` AND URL still `/admin/ldap-mappings/create`. (DB unique index on `LdapGroupName`, no pre-check.)
- `finally`: `await CliRunner.DeleteRoleMappingAsync(seededId);`.
**Step 3 — run:** `dotnet test --filter "Save_MissingGroupName_ShowsRequiredError|Create_DuplicateGroup_ShowsSaveFailedError"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): LDAP missing-field + duplicate-group edge cases (Wave 4)`.
**Acceptance:** both pass; no `zztest-grp-*` mapping residue (`security role-mapping list` clean).
---
## Task 6: Audit Log edge p1 — filter combination + empty-after-apply
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs`
**Gate:** match the file — `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)`. Seeding uses `AuditDataSeeder` with a per-run `targetPrefix = $"playwright-test/wave4-audit/{runId}/"`.
**Existing coverage to NOT duplicate:** channel-narrowing (single channel), drawer JSON pretty-print, copy-as-curl visible for ApiInbound (positive), correlation/execution drill-ins. The new value is **multi-filter combination** and **empty-after-apply**.
**Step 1 — `FilterCombination_ChannelPlusTarget_NarrowsToMatch`** (`[SkippableFact]`). Seed two rows differing in channel; apply channel+target together:
- Seed row 1: `InsertAuditEventAsync(eventId: Guid.NewGuid(), occurredAtUtc: now, channel: "ApiOutbound", kind: "ApiCall", status: "Success", target: targetPrefix + "match", …)`.
- Seed row 2: `…channel:"DbOutbound", kind:"DbWrite", target: targetPrefix + "other", …`.
- Navigate `/audit/log`; set channel `[data-test='filter-channel-select']``"ApiOutbound"`; type the exact `targetPrefix + "match"` into `#audit-target`; click `[data-test='filter-apply']`.
- Assert: the ApiOutbound/match row is visible (`[data-test='grid-row-{row1 eventId}']`) and the DbOutbound row count is 0 (`page.Locator($"[data-test='grid-row-{row2 eventId}']")``ToHaveCountAsync(0)`). This proves the two filters AND together.
- `finally`: `await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);`.
**Step 2 — `EmptyResults_AfterApply_ShowsEmptyState`** (`[SkippableFact]`). A fresh random executionId GUID matches nothing:
- Navigate `/audit/log`; type a fresh `Guid.NewGuid().ToString()` into `#audit-execution-id` (exact-match); click `[data-test='filter-apply']`.
- Assert: zero rows (`page.Locator("[data-test^='grid-row-']")``ToHaveCountAsync(0)`) AND the empty-state literal is visible: `Expect(page.Locator("[data-test='audit-results-grid']")).ToContainTextAsync("No audit events match the current filter.")`.
- No seeding/teardown needed (guaranteed-empty by construction).
**Step 3 — run:** `dotnet test --filter "FilterCombination_ChannelPlusTarget_NarrowsToMatch|EmptyResults_AfterApply_ShowsEmptyState"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Audit Log filter-combination + empty-state edge cases (Wave 4)`.
**Acceptance:** both pass; no `playwright-test/wave4-audit/*` rows remain (`DeleteByTargetPrefixAsync` ran).
---
## Task 7: Audit Log edge p2 — non-API row omits cURL + drawer close (X/backdrop/footer)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs`
**Blocked by:** Task 6 (same file).
**Step 1 — `NonApiRow_Drawer_OmitsCopyAsCurl`** (`[SkippableFact]`). The cURL button renders iff channel ∈ {ApiOutbound, ApiInbound}:
- Seed one `DbOutbound`/`DbWrite` row with `target: targetPrefix + "dbrow"`.
- Navigate `/audit/log`; type `targetPrefix + "dbrow"` into `#audit-target`; Apply; click the row `[data-test='grid-row-{eventId}']`.
- Assert drawer open (`Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync()`) AND cURL absent: `page.Locator("[data-test='copy-as-curl']")``ToHaveCountAsync(0)`. (Existing test already proves the POSITIVE for ApiInbound; this is the negative.)
- `finally`: delete by prefix.
**Step 2 — `Drawer_CloseControls_DismissTheDrawer`** (`[SkippableFact]`). Verify the X (and footer) close paths (Escape is NOT wired — intentionally omitted; see plan header):
- Seed one `ApiOutbound` row; navigate; filter by its target; Apply; click the row to open the drawer.
- Assert drawer visible; click the X `[data-test='drawer-close']`; assert drawer gone (`ToHaveCountAsync(0)` on `[data-test='audit-drilldown-drawer']`).
- Re-open the row; click the footer Close `[data-test='drawer-close-footer']`; assert drawer gone again. (Two close affordances in one test; optionally add the backdrop `[data-test='drawer-backdrop']` path as a third.)
- Add a code comment: `// Escape-to-close is not wired on AuditDrilldownDrawer (only ExecutionDetailModal has a keydown handler); covering X/footer/backdrop, the paths that ARE wired.`
- `finally`: delete by prefix.
**Step 3 — run:** `dotnet test --filter "NonApiRow_Drawer_OmitsCopyAsCurl|Drawer_CloseControls_DismissTheDrawer"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Audit Log non-API-no-cURL + drawer-close edge cases (Wave 4)`.
**Acceptance:** both pass; no audit residue.
---
## Task 8: Site Calls edge — status filter + empty state
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs`
**Gate:** `Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason)` (the const is local to this file). Marker `targetPrefix = $"playwright-test/wave4-sc/{runId}/"`. Seed with `sourceSite:"site-a"` (permitted) so rows survive `FilterPermittedAsync`.
**Existing coverage to NOT duplicate:** channel-narrowing, audit drill-in link, Retry/Discard visibility + click-throughs. The new value is **status filter** and **empty state**.
**Step 1 — `StatusFilter_NarrowsToSelectedStatus`** (`[SkippableFact]`). Seed a Parked row and a Delivered row sharing the prefix; filter status=Parked:
- Seed row P: `InsertSiteCallAsync(Guid.NewGuid(), channel:"ApiOutbound", target: targetPrefix + "parked", sourceSite:"site-a", status:"Parked", retryCount:1, createdAtUtc: now, updatedAtUtc: now)`.
- Seed row D: `…target: targetPrefix + "delivered", status:"Delivered", …`.
- Navigate `/site-calls/report`; select `#sc-status``"Parked"`; set `#sc-search` to the EXACT `targetPrefix + "parked"` via `SetSearchKeywordAsync`; click `[data-test='site-calls-query']`.
- Assert: the Parked marker row is visible (`tbody tr` HasText = `targetPrefix + "parked"`), its status badge reads `Parked`; then change `#sc-search` to `targetPrefix + "delivered"` + status still Parked + Query → 0 rows (the Delivered row is excluded by the status filter). This proves status narrows.
- `finally`: `await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);`.
**Step 2 — `EmptyState_NoMatch_ShowsEmptyCard`** (`[SkippableFact]`). A per-run GUID target matches nothing:
- Navigate `/site-calls/report`; set `#sc-search` to `$"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none"` via `SetSearchKeywordAsync`; click Query.
- Assert: zero data rows AND empty-state literal visible: `Expect(page.Locator(".card-body")).ToContainTextAsync("No cached calls match the current filters.")` (or `text=No site calls`). No seeding/teardown.
**Step 3 — run:** `dotnet test --filter "StatusFilter_NarrowsToSelectedStatus|EmptyState_NoMatch_ShowsEmptyCard"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Site Calls status-filter + empty-state edge cases (Wave 4)`.
**Acceptance:** both pass; no `playwright-test/wave4-sc/*` rows remain.
---
## Task 9: Site Calls keyset pagination
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs`
**Blocked by:** Task 8 (same file).
**Key mechanic:** page size 50; Next enabled only when the current page is EXACTLY full (50). `Target` is exact-match → seed **51 rows sharing ONE identical target string** so one `#sc-search` keyword selects all 51; stagger `createdAtUtc` for strict keyset order; `sourceSite:"site-a"`.
**Step 1 — `Pagination_KeysetNextAndPrev_TraversesPages`** (`[SkippableFact]`, `SiteCallDataSeeder` gate):
- `var sharedTarget = $"playwright-test/wave4-scpage/{runId}/row";`
- Seed 51 rows in a loop (one connection-per-call is acceptable for a live-cluster fact): `for (int i = 0; i < 51; i++) await SiteCallDataSeeder.InsertSiteCallAsync(Guid.NewGuid(), "ApiOutbound", sharedTarget, "site-a", "Delivered", 0, now.AddSeconds(-i), now.AddSeconds(-i));` — identical target, descending createdAt so the keyset order is deterministic.
- Navigate `/site-calls/report`; set `#sc-search` = `sharedTarget` via `SetSearchKeywordAsync`; click `[data-test='site-calls-query']`.
- Assert page 1: `Expect(page.Locator("tbody tr")).ToHaveCountAsync(50)`; page indicator contains `Page 1`; prev `[data-test='site-calls-prev']` is `disabled`; next `[data-test='site-calls-next']` is enabled.
- Click Next; assert page 2: 1 row (`ToHaveCountAsync(1)`), indicator `Page 2`, prev now enabled, next now disabled (short page).
- Click Prev; assert back to 50 rows / `Page 1` / prev disabled. (Confirms the cursor stack round-trips.)
- `finally`: `await SiteCallDataSeeder.DeleteByTargetPrefixAsync($"playwright-test/wave4-scpage/{runId}/");`.
> Use web-first `Expect(...).ToBeDisabledAsync()`/`ToBeEnabledAsync()` for the button states and `ToHaveCountAsync` for rows — never `WaitForTimeout` + read. The `_loading` flag also disables the buttons mid-fetch, so assert the row count first (which waits for the new page) then the button states.
**Step 2 — run:** `dotnet test --filter Pagination_KeysetNextAndPrev_TraversesPages`. Expected: PASS (allow generous timeouts; 51 seeds + two page fetches).
**Step 3 — commit:** `test(playwright): Site Calls keyset pagination edge case (Wave 4)`.
**Acceptance:** passes; all 51 seeded rows removed (`DeleteByTargetPrefixAsync`).
---
## Task 10: Notification Report edge p1 — filter combination + detail modal
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs`
**Blocked by:** Task 1 (seeder generalization — though these two cases use the existing Parked insert, keep the dependency so the seeder change lands first and the file builds once).
**Gate:** `Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason)` (const local to file). Marker `var marker = $"zztest-notif-wave4-{runId}";` (the `ListName`).
**Existing coverage to NOT duplicate:** Retry/Discard click-throughs. New value: **filter combo** and **detail-modal open/close**.
**Step 1 — `FilterCombination_StatusPlusList_NarrowsToMatch`** (`[SkippableFact]`). Seed two Parked rows under the marker with distinct subjects; combine list+subject (or list+status) filters:
- Seed: `InsertParkedNotificationAsync(Guid.NewGuid(), marker, subject: "wave4-alpha")` and `…(…, marker, subject: "wave4-beta")`.
- Navigate `/notifications/report`; set `#no-list` = `marker` (exact); set `#no-status``"Parked"`; set `#no-search` = `"wave4-alpha"` via `SetSubjectKeywordAsync`; click Query (`button.btn-primary:has-text('Query')`).
- Assert: the `wave4-alpha` row visible, `wave4-beta` row count 0 (combined filters AND). Optionally flip `#no-search` to `wave4-beta` + Query → beta visible, alpha gone.
- `finally`: `await NotificationDataSeeder.DeleteByMarkerAsync(marker);`.
**Step 2 — `DetailModal_OpenOnDblClick_CloseViaXAndFooter`** (`[SkippableFact]`). Modal opens on double-click of a non-Actions cell:
- Seed one Parked row (`subject:"wave4-detail"`, marker); navigate; narrow by `#no-list`=marker + Query; locate the row (`tbody tr` HasText `wave4-detail`).
- Double-click the row's subject cell (`row.Locator("td", new(){ HasText = "wave4-detail" })``DblClickAsync()`); assert modal `.modal.show.d-block` visible with `.modal-title:has-text('Notification Detail')`.
- Close via X `button.btn-close[aria-label="Close"]`; assert modal gone (`ToHaveCountAsync(0)` on `.modal.show.d-block`).
- Re-open (double-click); close via footer `.modal-footer button.btn-outline-secondary:has-text('Close')`; assert gone. Comment that Escape-close is not wired (only X / footer / backdrop are).
- `finally`: `DeleteByMarkerAsync(marker)`.
**Step 3 — run:** `dotnet test --filter "FilterCombination_StatusPlusList_NarrowsToMatch|DetailModal_OpenOnDblClick_CloseViaXAndFooter"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Notification Report filter-combo + detail-modal edge cases (Wave 4)`.
**Acceptance:** both pass; no `zztest-notif-wave4-*` rows remain.
---
## Task 11: Notification Report edge p2 — stuck-only + pagination
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs`
**Blocked by:** Task 1 (uses the generalized seeder) and Task 10 (same file).
**Step 1 — `StuckOnlyFilter_NarrowsToStuckRows`** (`[SkippableFact]`). Seed one genuinely-stuck row (Retrying + back-dated) and one Parked@now row under one marker; toggle Stuck-only:
- `var stuckCreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15);` (older than the 10-min threshold).
- Seed stuck: `InsertNotificationAsync(Guid.NewGuid(), marker, "wave4-stuck", status:"Retrying", createdAt: stuckCreatedAt);`
- Seed non-stuck: `InsertParkedNotificationAsync(Guid.NewGuid(), marker, "wave4-fresh");`
- Navigate; set `#no-list` = marker; Query → assert both rows visible (2).
- Check `#no-stuck-only` (`CheckAsync`); click Query.
- Assert: only `wave4-stuck` visible (and it carries the stuck badge `span.badge.bg-warning.text-dark:has-text('Stuck')` / `tr.table-warning`); `wave4-fresh` row count 0.
- `finally`: `DeleteByMarkerAsync(marker)`.
> **⚠ validation-behavior protocol:** confirm the seeded Retrying+back-dated row is classified stuck by the running app (the threshold default is 10 min; 15 min gives margin). If the cluster's `StuckAgeThreshold` is configured higher, increase the back-date. If — contrary to the code read — `Notifications` requires a terminal/`TerminalAtUtc`-style column for stuck classification, fall back to the **negative** form: toggling Stuck-only ON drops the seeded Parked row to zero (empty state). Prefer the positive assertion.
**Step 2 — `Pagination_PageNumberNextAndPrev_TraversesPages`** (`[SkippableFact]`). Page size 50; pager renders only when `_totalCount > 50`. Seed 51 rows under one marker + shared subject token:
- `for (int i = 0; i < 51; i++) await NotificationDataSeeder.InsertParkedNotificationAsync(Guid.NewGuid(), marker, subject: $"wave4-page-{i:D2}");` (same marker → one cleanup; distinct subjects fine — filter by `#no-list`=marker selects all 51).
- Navigate; set `#no-list` = marker; Query.
- Assert page 1: 50 rows (`tbody tr` `ToHaveCountAsync(50)`), indicator contains `Page 1`, prev `button.btn-outline-secondary.btn-sm:has-text('Previous')` disabled, next `…:has-text('Next')` enabled.
- Click Next; assert page 2: 1 row, `Page 2`, prev enabled, next disabled.
- Click Prev; back to 50 / `Page 1`.
- `finally`: `DeleteByMarkerAsync(marker)`.
**Step 3 — run:** `dotnet test --filter "StuckOnlyFilter_NarrowsToStuckRows|Pagination_PageNumberNextAndPrev_TraversesPages"`. Expected: 2 PASS.
**Step 4 — commit:** `test(playwright): Notification Report stuck-only + pagination edge cases (Wave 4)`.
**Acceptance:** both pass; no `zztest-notif-wave4-*` rows remain.
---
## Task 12: Wave 4 verification + residue check
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Blocked by:** Tasks 011.
**Files:** none (verification only; may touch the plan's `.tasks.json` to mark complete).
**Step 1 — full build:** `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → clean under `TreatWarningsAsErrors=true`.
**Step 2 — full suite:** run the entire Playwright test project against the live cluster. Expected: **0 failed**; new Wave-4 facts pass; skips logged (only the genuinely-environmental ones — e.g. any pre-existing SMTP no-op skip). Capture the pass/fail/skip tally.
**Step 3 — residue scan (must be zero):**
- `scadabridge … site list` → no `zztest-*` site.
- `scadabridge … template list` → no `zztest-tmpl-*`.
- `scadabridge … instance list` → no `zztest-*` instance (the `DeploymentFixture` template restored to instance-free).
- `scadabridge … security role-mapping list` → no `zztest-grp-*`.
- Direct-SQL marker scan: no `playwright-test/wave4-%` rows in `AuditLog` or `SiteCalls`; no `zztest-notif-wave4-%` `ListName` in `Notifications`.
- `site-a` left exactly as found (the duplicate-identifier test persisted nothing; no test mutated site-a).
**Step 4 — app-diff guard:** `git diff --stat` shows **only** files under `tests/…PlaywrightTests/` and `docs/plans/…wave4*`**zero `src/` changes** (no `data-test` hooks were needed this wave). If any `src/` file changed, that's a defect — investigate before completing.
**Step 5 — mark complete:** update `…-wave4.md.tasks.json` statuses → `completed`; commit `docs(plans): mark Wave 4 tasks complete`.
**Acceptance:** full suite 0-failed; zero residue; clean build; no `src/` changes; `site-a` unchanged.
---
## Scope guard (YAGNI)
No new fixtures, no new test files, no page-object framework, no CI/runner/parallelization changes, no visual-regression. The only non-test additions are two CLI helpers (Task 0) and one seeder generalization (Task 1). **No app-code changes and no `docker/deploy.sh` rebuild** — confirmed every edge surface has an existing stable selector after dropping the two un-wired Escape legs and the disproportionate Audit-pagination case.
## Success criteria
All 6 already-covered pages gain their duplicate/cancel/empty/filter-combo/pagination edge assertions (~18 new `[SkippableFact]`s across 10 test tasks); the full suite stays green with logged skips; zero residue; clean build. Wave 4 closes the coverage-fill effort.