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 ad8fbe94..b79cd45c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs @@ -166,4 +166,125 @@ public class NotificationActionTests await NotificationDataSeeder.DeleteByMarkerAsync(marker); } } + + /// + /// Combined-filter narrowing: two Parked rows share one ListName marker but carry + /// distinct subjects. Applying the exact-match list filter (the marker) plus + /// status=Parked plus the subject keyword for only ONE of the two rows must surface that + /// row and exclude its sibling. This proves the filters compose (AND-narrowing) rather + /// than widening — the marker isolates the pair from ambient cluster rows, and the + /// subject keyword discriminates between them. + /// + [SkippableFact] + public async Task FilterCombination_StatusPlusList_NarrowsToMatch() + { + Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var marker = $"zztest-notif-wave4-{runId}"; + + // Two Parked rows under the SAME ListName marker, distinct subjects. + await NotificationDataSeeder.InsertParkedNotificationAsync( + Guid.NewGuid(), marker, "wave4-alpha", "site-a"); + await NotificationDataSeeder.InsertParkedNotificationAsync( + Guid.NewGuid(), marker, "wave4-beta", "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(); + + // #no-list is an exact-match text input bound @bind (commit-on-change), so + // FillAsync (which only fires `input`) must be followed by an explicit + // `change` dispatch to commit the bound value before the Query roundtrip — + // same rationale as SetSubjectKeywordAsync for the subject box. + await page.Locator("#no-list").FillAsync(marker); + await page.Locator("#no-list").DispatchEventAsync("change"); + + // Status select commits on its own SelectOptionAsync change event. + await page.Locator("#no-status").SelectOptionAsync("Parked"); + + // Subject keyword for ONLY the alpha row. + await SetSubjectKeywordAsync(page, "wave4-alpha"); + + await page.Locator("button.btn-primary:has-text('Query')").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The combined filters narrow to alpha: alpha visible, beta absent. + var alphaRow = page.Locator("tbody tr", new() { HasText = "wave4-alpha" }); + await Assertions.Expect(alphaRow).ToBeVisibleAsync(new() { Timeout = 15_000 }); + await Assertions.Expect( + page.Locator("tbody tr").Filter(new() { HasText = "wave4-beta" })) + .ToHaveCountAsync(0); + } + finally + { + await NotificationDataSeeder.DeleteByMarkerAsync(marker); + } + } + + /// + /// The row detail modal opens on a DOUBLE-CLICK of a non-Actions row cell (there is no + /// dedicated open button) and closes via the header X and the footer Close button. Both + /// close affordances are wired to the same CloseDetail handler; this exercises + /// each path independently (open → X-close → reopen → footer-close). + /// + [SkippableFact] + public async Task DetailModal_OpenOnDblClick_CloseViaXAndFooter() + { + Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var marker = $"zztest-notif-wave4-{runId}"; + + await NotificationDataSeeder.InsertParkedNotificationAsync( + Guid.NewGuid(), marker, "wave4-detail", "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 seeded row by its exact ListName marker (+ Parked status). + await page.Locator("#no-list").FillAsync(marker); + await page.Locator("#no-list").DispatchEventAsync("change"); + await page.Locator("#no-status").SelectOptionAsync("Parked"); + await page.Locator("button.btn-primary:has-text('Query')").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var row = page.Locator("tbody tr").Filter(new() { HasText = "wave4-detail" }); + await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 }); + + // Open the modal: double-click the SUBJECT cell (a non-Actions cell). The + // Actions cell carries @ondblclick:stopPropagation, so double-clicking it would + // not open the modal — the subject cell is the correct target. + await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync(); + await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator(".modal-title")).ToContainTextAsync("Notification Detail"); + + // Close via the header X. + await page.ClickAsync("button.btn-close[aria-label='Close']"); + await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0); + + // Re-open (double-click the subject cell again). + await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync(); + await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync(); + + // Close via the footer Close button. + await page.ClickAsync(".modal-footer button.btn-outline-secondary:has-text('Close')"); + await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0); + + // Escape-to-close is NOT wired on the Notification detail modal (no keydown handler); covering the X and footer-Close paths that ARE wired (backdrop also closes but is omitted for brevity). + } + finally + { + await NotificationDataSeeder.DeleteByMarkerAsync(marker); + } + } }