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), the inline /// double-click rename on an area node's label, the right-click "Move to Area…" /// flows for both an area (MoveAreaDialog) and an instance /// (MoveInstanceDialog), and the read-only "Diff" comparison /// (DiffDialog) for a deployed instance. /// /// /// 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. /// /// /// /// Move flows: both "Move to Area…" context items open a one-<select> /// modal whose options carry the area name as their text (root-level areas render /// without indentation, so an exact visible-text match resolves). Unlike a text /// input, an HTML <select> fires onchange immediately on /// selection, so /// commits the @bind directly (no DispatchEventAsync dance). The /// area dialog title is "Move area '…' to…" (h6) and the instance dialog /// "Move '…' to…" (h6); both succeed with a single "moved" toast. /// /// /// /// Diff: the instance "Diff" item is disabled for a NotDeployed /// instance, so the Diff fact deploys first (over the CLI) to enable it. The /// resulting DiffDialog is computed centrally (no site relay), so the /// comparison is deterministic for a deployed instance; its title is /// "Deployment Diff — <uniqueName>" (h5, em-dash) and it closes via /// the footer Close button. /// /// [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); } } [SkippableFact] public async Task MoveArea_UnderAnotherArea_ShowsMovedToast() { Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); var parentName = CliRunner.UniqueName("mvpar"); var childName = CliRunner.UniqueName("mvchild"); var parentId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, parentName); var childId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, childName); try { var page = await OpenStableTopologyAsync(_pw); var childRow = page.Locator("div.tv-row", new() { HasText = childName }); await Assertions.Expect(childRow).ToBeVisibleAsync(new() { Timeout = 10_000 }); await childRow.ScrollIntoViewIfNeededAsync(); await childRow.ClickAsync(new() { Button = MouseButton.Right }); await page.Locator(".dropdown-menu.show button.dropdown-item:has-text('Move to Area')").ClickAsync(); // MoveAreaDialog title is "Move area '' to…" (h6) — distinct from the // instance dialog ("Move '' to…"), so scope by the "Move area" text. var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('Move area'))"); await Assertions.Expect(dialog).ToBeVisibleAsync(); // Root-level area options render without indentation, so the parent's name is // the option's exact visible text. A