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(); + } }