diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs
index b3b93b7e..b416d59c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs
@@ -289,4 +289,152 @@ public class NotificationActionTests
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
}
}
+
+ ///
+ /// The "Stuck only" filter narrows the result set to genuinely stuck rows. A
+ /// notification IsStuck iff its Status ∈ {Pending, Retrying} AND its
+ /// CreatedAt < now − StuckAgeThreshold (default 10 min). Two rows share one
+ /// ListName marker: a genuinely-stuck row (Retrying, back-dated 15 min for
+ /// margin) and a non-stuck Parked@now row. Querying by the marker alone surfaces
+ /// BOTH; toggling Stuck-only ON must drop the fresh row and keep only the stuck one.
+ /// This uses the POSITIVE form (stuck row present, fresh row gone): the default
+ /// StuckAgeThreshold is 10 min — verified in NotificationOutboxOptions and the
+ /// docker cluster carries no override — so a Retrying row back-dated 15 min passes both
+ /// the repository's StuckOnly SQL predicate and the page's IsStuck derivation.
+ ///
+ [SkippableFact]
+ public async Task StuckOnlyFilter_NarrowsToStuckRows()
+ {
+ Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
+
+ var runId = Guid.NewGuid().ToString("N");
+ var marker = $"zztest-notif-wave4-{runId}";
+
+ // Genuinely-stuck row: Retrying + back-dated 15 min (> the 10-min StuckAgeThreshold).
+ await NotificationDataSeeder.InsertNotificationAsync(
+ Guid.NewGuid(), marker, "wave4-stuck",
+ status: "Retrying", createdAt: DateTimeOffset.UtcNow.AddMinutes(-15),
+ sourceSite: "site-a");
+ // Non-stuck row: Parked @ now (terminal-ish status + fresh timestamp → not stuck).
+ await NotificationDataSeeder.InsertParkedNotificationAsync(
+ Guid.NewGuid(), marker, "wave4-fresh", "site-a");
+
+ try
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
+
+ // Narrow to the pair by the exact ListName marker (commit the @bind value with a
+ // change dispatch — FillAsync only fires `input`), then Query.
+ await page.Locator("#no-list").FillAsync(marker);
+ await page.Locator("#no-list").DispatchEventAsync("change");
+ await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // The marker alone selects BOTH rows (proves the stuck filter is what narrows,
+ // not the marker).
+ await Assertions.Expect(
+ page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" }))
+ .ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(
+ page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" }))
+ .ToHaveCountAsync(1);
+
+ // Toggle Stuck-only ON and re-Query.
+ await page.Locator("#no-stuck-only").CheckAsync();
+ await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Only the stuck row survives: fresh gone, stuck present.
+ await Assertions.Expect(
+ page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" }))
+ .ToHaveCountAsync(0, new() { Timeout = 15_000 });
+ var stuckRow = page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" });
+ await Assertions.Expect(stuckRow).ToHaveCountAsync(1);
+
+ // And the surviving row carries the stuck badge (positive corroboration that the
+ // page classifies it as stuck, not merely that the SQL filter kept it).
+ await Assertions.Expect(
+ stuckRow.Locator("span.badge.bg-warning.text-dark:has-text('Stuck')"))
+ .ToBeVisibleAsync();
+ }
+ finally
+ {
+ await NotificationDataSeeder.DeleteByMarkerAsync(marker);
+ }
+ }
+
+ ///
+ /// Page-number pagination: the pager renders only when _totalCount > 50
+ /// (page size 50). Seeding 51 rows under one ListName marker yields exactly two
+ /// pages (50 + 1). The test drives the page-NUMBER pager forward (Next) and back
+ /// (Previous), asserting the row count, the page indicator, and the Previous/Next
+ /// enabled/disabled states at each step. Per the harness contract the row COUNT is
+ /// asserted FIRST at each step — it waits out the fetch, during which _loading
+ /// disables both pager buttons — so the button-state assertions never race the in-flight
+ /// query.
+ ///
+ [SkippableFact]
+ public async Task Pagination_PageNumberNextAndPrev_TraversesPages()
+ {
+ Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
+
+ var runId = Guid.NewGuid().ToString("N");
+ var marker = $"zztest-notif-wave4-{runId}";
+
+ // 51 Parked rows under one marker → 2 pages (50 + 1); the pager renders (> 50).
+ for (int i = 0; i < 51; i++)
+ {
+ await NotificationDataSeeder.InsertParkedNotificationAsync(
+ Guid.NewGuid(), marker, $"wave4-page-{i:D2}", "site-a");
+ }
+
+ try
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
+
+ await page.Locator("#no-list").FillAsync(marker);
+ await page.Locator("#no-list").DispatchEventAsync("change");
+ await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Pager locators. Previous/Next are scoped by their text (the page header's
+ // Refresh button is also a .btn-outline-secondary.btn-sm). The indicator span is
+ // the pager's only `span.text-muted.small` once rows render — the "Loading…"
+ // placeholder that shares that class renders only while `_notifications == null`.
+ var prev = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')");
+ var next = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')");
+ var indicator = page.Locator("span.text-muted.small");
+
+ // ── Page 1 ── (count first — it waits out the fetch — then indicator + buttons).
+ await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
+ await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
+ await Assertions.Expect(prev).ToBeDisabledAsync();
+ await Assertions.Expect(next).ToBeEnabledAsync();
+
+ // ── Forward to page 2 ──
+ await next.ClickAsync();
+ await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(indicator).ToContainTextAsync("Page 2");
+ await Assertions.Expect(prev).ToBeEnabledAsync();
+ await Assertions.Expect(next).ToBeDisabledAsync();
+
+ // ── Back to page 1 ──
+ await prev.ClickAsync();
+ await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
+ await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
+ await Assertions.Expect(prev).ToBeDisabledAsync();
+ }
+ finally
+ {
+ await NotificationDataSeeder.DeleteByMarkerAsync(marker);
+ }
+ }
}