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