diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs new file mode 100644 index 00000000..9f0d5119 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Playwright; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; + +/// +/// E2E coverage for the Topology page's area authoring surface — creating an area +/// from the toolbar "+ Area" dialog (the site-picker variant), and the inline +/// double-click rename on an area node's label. +/// +/// +/// Every fact opens the page through , which +/// FIRST unchecks the #live-updates switch. The page reloads the whole tree +/// on a 15s timer; left on, that reload collapses freshly-expanded nodes and tears +/// down any open dialog or in-progress rename input mid-interaction. With it off +/// the tree stays stable for the duration of the action. The helper then clicks +/// Expand all areas so nested area nodes are rendered and locatable. +/// +/// +/// +/// Toolbar create: "+ Area" opens CreateAreaDialog in its site-picker mode, +/// where the FIRST <select> is the Site picker (option value = site id) +/// and the second is the parent area. Success closes the dialog, raises an +/// "Area '…' created." toast, and renders the new node. +/// +/// +/// +/// Inline rename: double-clicking an area's span.tv-label swaps it for a +/// rename input. Enter commits, Escape reverts, and blur CANCELS — so a +/// committing fact fills the input then presses Enter on that SAME input, never +/// clicking elsewhere first (a click would blur and silently discard the edit). +/// +/// +/// +/// Areas these tests create/rename are ephemeral and cleaned up in a +/// finally: the rename facts create their own area over the CLI (known id → +/// delete by id), while the toolbar-create fact never learns the new id from the +/// UI, so it sweeps by name prefix +/// ( → +/// ). A CLI read-back after each action +/// confirms the change actually persisted, not just that the toast appeared. +/// +/// +[Collection("Playwright")] +public class TopologyAreaTests : IClassFixture +{ + private readonly PlaywrightFixture _pw; + private readonly DeploymentFixture _cluster; + + public TopologyAreaTests(PlaywrightFixture pw, DeploymentFixture cluster) + { + _pw = pw; + _cluster = cluster; + } + + [SkippableFact] + public async Task CreateArea_ViaToolbar_AppearsInTreeAndPersists() + { + Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); + + var areaName = CliRunner.UniqueName("topoarea"); + try + { + var page = await OpenStableTopologyAsync(_pw); + + await page.Locator("button:has-text('+ Area')").ClickAsync(); + var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('New Area'))"); + await Assertions.Expect(dialog).ToBeVisibleAsync(); + + // First select = Site (option value = site.Id); then the name; then Create. + await dialog.Locator("select").First.SelectOptionAsync( + new SelectOptionValue { Value = _cluster.SiteAId.ToString() }); + await dialog.Locator("input[placeholder='Area name']").FillAsync(areaName); + await dialog.Locator("button.btn.btn-primary.btn-sm:has-text('Create')").ClickAsync(); + + await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 }); + await Assertions.Expect(page.Locator($"span.tv-label:has-text('{areaName}')")) + .ToBeVisibleAsync(new() { Timeout = 10_000 }); + + var ids = await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, areaName); + Assert.Single(ids); + } + finally + { + foreach (var id in await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, areaName)) + await CliRunner.DeleteAreaAsync(id); + } + } + + [SkippableFact] + public async Task RenameArea_EnterCommits_PersistsNewName() + { + Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); + + var original = CliRunner.UniqueName("rnarea"); + var renamed = CliRunner.UniqueName("rndone"); + var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, original); + try + { + var page = await OpenStableTopologyAsync(_pw); + + var label = page.Locator($"span.tv-label:has-text('{original}')"); + await Assertions.Expect(label).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await label.DblClickAsync(); + + var input = page.Locator($"input[aria-label='Rename {original}']"); + await Assertions.Expect(input).ToBeVisibleAsync(); + await input.FillAsync(renamed); + // The input is @bind="_renameBuffer" with the default onchange trigger, which a + // raw Enter keystroke does NOT fire (onchange only fires on blur in the browser) — + // and blur CANCELS the rename. So dispatch a `change` event to flush the bound + // value to the server WITHOUT losing focus, then press Enter to run CommitRename + // against the now-updated _renameBuffer. + await input.DispatchEventAsync("change"); + await input.PressAsync("Enter"); // commit WITHOUT blurring (blur would cancel) + + await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 }); + await Assertions.Expect(page.Locator($"span.tv-label:has-text('{renamed}')")) + .ToBeVisibleAsync(new() { Timeout = 10_000 }); + + Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, renamed)); + } + finally + { + await CliRunner.DeleteAreaAsync(areaId); + } + } + + [SkippableFact] + public async Task RenameArea_Escape_RevertsToOriginalLabel() + { + Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); + + var original = CliRunner.UniqueName("esarea"); + var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, original); + try + { + var page = await OpenStableTopologyAsync(_pw); + + var label = page.Locator($"span.tv-label:has-text('{original}')"); + await Assertions.Expect(label).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await label.DblClickAsync(); + + var input = page.Locator($"input[aria-label='Rename {original}']"); + await Assertions.Expect(input).ToBeVisibleAsync(); + await input.FillAsync(CliRunner.UniqueName("discarded")); + await input.PressAsync("Escape"); + + await Assertions.Expect(input).ToHaveCountAsync(0, new() { Timeout = 5_000 }); + await Assertions.Expect(page.Locator($"span.tv-label:has-text('{original}')")).ToBeVisibleAsync(); + Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, original)); + } + finally + { + await CliRunner.DeleteAreaAsync(areaId); + } + } + + /// + /// Navigates to the Topology page and returns a page whose tree is stable and + /// fully expanded: it unchecks the #live-updates switch (stopping the 15s + /// timer that would otherwise reload the tree and collapse expansions / tear down + /// open dialogs and rename inputs), then clicks Expand all areas so nested + /// area nodes are rendered and locatable. + /// + private static async Task OpenStableTopologyAsync(PlaywrightFixture pw) + { + var page = await pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + var live = page.Locator("#live-updates"); + if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload + await page.Locator("button[aria-label='Expand all areas']").ClickAsync(); + return page; + } +}