# 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 1–2 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 0–11.

**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.
