diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs
index 0ff7d3fc..3677d7c7 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs
@@ -387,4 +387,108 @@ public class SiteCallsPageTests
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
+
+ ///
+ /// The Status dropdown filters the grid to the selected lifecycle status.
+ /// Seeds two rows sharing a per-run Target prefix — one Parked, one
+ /// Delivered — then filters status=Parked: the Parked marker row
+ /// surfaces (with a Parked status badge) while the Delivered marker
+ /// row is excluded even though both share the prefix. This mirrors
+ /// but exercises the
+ /// STATUS axis rather than the channel axis.
+ ///
+ /// SourceSite is site-a (a permitted site) on both rows —
+ /// FilterPermittedAsync drops rows whose source site is not permitted.
+ ///
+ ///
+ [SkippableFact]
+ public async Task StatusFilter_NarrowsToSelectedStatus()
+ {
+ Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
+
+ var runId = Guid.NewGuid().ToString("N");
+ var targetPrefix = $"playwright-test/wave4-sc/{runId}/";
+ var parkedId = Guid.NewGuid();
+ var deliveredId = Guid.NewGuid();
+ var now = DateTime.UtcNow;
+
+ try
+ {
+ // Two rows sharing the prefix: one Parked, one Delivered. site-a is a
+ // permitted source site (FilterPermittedAsync keeps it).
+ await SiteCallDataSeeder.InsertSiteCallAsync(
+ trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
+ sourceSite: "site-a", status: "Parked", retryCount: 3,
+ lastError: "HTTP 503 from ERP", httpStatus: 503,
+ createdAtUtc: now, updatedAtUtc: now);
+ await SiteCallDataSeeder.InsertSiteCallAsync(
+ trackedOperationId: deliveredId, channel: "ApiOutbound", target: targetPrefix + "delivered",
+ sourceSite: "site-a", status: "Delivered", retryCount: 0,
+ createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Filter status=Parked and scope to the exact Parked marker — the row
+ // surfaces and its status badge reads Parked.
+ await page.Locator("#sc-status").SelectOptionAsync("Parked");
+ await SetSearchKeywordAsync(page, targetPrefix + "parked");
+ await page.ClickAsync("[data-test='site-calls-query']");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
+ await Assertions.Expect(parkedRow).ToBeVisibleAsync();
+ await Assertions.Expect(parkedRow.Locator("span.badge:has-text('Parked')")).ToBeVisibleAsync();
+
+ // Same status=Parked filter, now searching the Delivered marker: the
+ // Delivered row is excluded by the status filter, so no row carries
+ // its marker. The retrying ToHaveCount waits out the re-render.
+ await SetSearchKeywordAsync(page, targetPrefix + "delivered");
+ await page.ClickAsync("[data-test='site-calls-query']");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(
+ page.Locator("tbody tr").Filter(new() { HasText = targetPrefix + "delivered" }))
+ .ToHaveCountAsync(0);
+ }
+ finally
+ {
+ await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
+ }
+ }
+
+ ///
+ /// When the filters match no rows the grid renders the empty-state card
+ /// rather than a table. A per-run GUID Target is searched (exact match), so
+ /// nothing can match — guaranteed empty without seeding. Asserts both the
+ /// absence of data rows and the empty-state literal.
+ ///
+ /// The empty-state literal lives in div.card > div.card-body…, but
+ /// the filter card also uses .card-body, so a bare .card-body
+ /// locator is ambiguous under strict mode. We assert the literal via
+ /// instead.
+ ///
+ ///
+ [SkippableFact]
+ public async Task EmptyState_NoMatch_ShowsEmptyCard()
+ {
+ Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // A per-run GUID target matches nothing (exact match → guaranteed empty).
+ await SetSearchKeywordAsync(page, $"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none");
+ await page.ClickAsync("[data-test='site-calls-query']");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // No data rows, and the empty-state literal renders. GetByText avoids the
+ // strict-mode ambiguity of the shared .card-body class.
+ await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(0);
+ await Assertions.Expect(
+ page.GetByText("No cached calls match the current filters."))
+ .ToBeVisibleAsync();
+ }
}