diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs new file mode 100644 index 00000000..5a03a475 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs @@ -0,0 +1,103 @@ +using Microsoft.Playwright; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design; + +[Collection("Playwright")] +public class DataConnectionCrudTests +{ + private readonly PlaywrightFixture _pw; + + public DataConnectionCrudTests(PlaywrightFixture pw) + { + _pw = pw; + } + + /// + /// CLI-create a data connection on site-a, then exercise the high-value UI delete + /// path: the connection renders as a child node in the TreeView, the per-node kebab + /// → Delete → confirm dialog removes it, a success toast fires, and the node is gone. + /// The create form requires a brittle OPC UA endpoint sub-editor, so we seed via the + /// CLI (a minimal create needs no primary config) and only drive the delete via the UI. + /// The best-effort finally is a safety net: the happy path deletes via the UI. + /// + [SkippableFact] + public async Task CliCreated_Connection_DeletesViaTree() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var siteId = await CliRunner.ResolveSiteIdAsync("site-a"); + var name = CliRunner.UniqueName("dconn"); + var id = await CliRunner.CreateDataConnectionAsync(siteId, name); + + try + { + var page = await _pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync(); + + // Reveal all nodes so the new connection (a child of its site) is in the tree. + await page.Locator("button.btn-outline-secondary.btn-sm.dropdown-toggle:has-text('Bulk actions')") + .ClickAsync(); + await page.Locator(".dropdown-item:has-text('Expand all')").ClickAsync(); + + var node = page.Locator("span.tv-label", new() { HasText = name }); + await Assertions.Expect(node).ToBeVisibleAsync(new() { Timeout = 10_000 }); + + // The kebab is opacity:0 until its row is hovered; hovering first makes the + // reveal deterministic, then a forced click sidesteps any residual flake. + await node.HoverAsync(); + await page.Locator($"button.dc-kebab[aria-label='More actions for {name}']") + .ClickAsync(new() { Force = true }); + + // Every connection node renders its own .text-danger "Delete" item, so scope the + // click to the one open menu. Bootstrap puts .show on the active node's + // ul.dropdown-menu (and auto-closes the others), making this match uniquely. + await page.Locator(".dropdown-menu.show .dropdown-item.text-danger:has-text('Delete')") + .ClickAsync(); + + await Assertions.Expect(page.Locator(".modal-title:has-text('Delete Connection')")).ToBeVisibleAsync(); + await page.Locator(".modal-footer .btn-danger").ClickAsync(); + + // Single web-first assertion on the success toast — toasts auto-dismiss at 5s, + // so we do NOT chase the toast body in a second sequential check. + await Assertions.Expect(page.Locator(".toast", new() { HasText = "deleted" })) + .ToHaveCountAsync(1, new() { Timeout = 15_000 }); + + // The node must be gone from the tree. + await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = name })) + .ToHaveCountAsync(0, new() { Timeout = 10_000 }); + } + finally + { + // Best-effort teardown: the happy path deletes via the UI, so this finds + // nothing; it only fires if the UI path failed mid-way. + await CliRunner.DeleteDataConnectionAsync(id); + } + } + + /// + /// The "+ Connection" button is gated on a tree-node selection: it starts disabled + /// and only enables once a site (or connection) node is selected. Mutates nothing. + /// + [SkippableFact] + public async Task CreateButton_GatedOnNodeSelection() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync(); + + var addButton = page.Locator("button.btn.btn-primary.btn-sm:has-text('+ Connection')"); + await Assertions.Expect(addButton).ToBeDisabledAsync(); + + // Selecting any site node (site-a exists on the live cluster) satisfies the gate. + await page.Locator("span.tv-label").First.ClickAsync(); + + await Assertions.Expect(addButton).ToBeEnabledAsync(); + } +}