Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs
T
Joseph Doherty 9e243493fb ui: Central UI density/consistency sweep + Theme 0.4.1
Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
2026-08-11 05:50:12 -04:00

237 lines
13 KiB
C#

using Microsoft.Data.SqlClient;
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
/// <summary>
/// Browser-level coverage for the M9 design surfaces, run against the live dev cluster:
/// <list type="bullet">
/// <item>the <c>/design/templates</c> tree search filter (TemplateFolderTree pruning),</item>
/// <item>the <c>/design/schema-library</c> page (render-smoke + UI create→delete round-trip),</item>
/// <item>the <c>/design/connections</c> "Move to Site…" dialog render (MoveDataConnectionDialog).</item>
/// </list>
/// Every test seeds only uniquely-named throwaway entities (<c>zztest-*</c>) and best-effort
/// cleans them up; none mutate the canonical <c>site-a</c>/<c>site-b</c>/<c>site-c</c> rows or any
/// pre-seeded entity. The move test asserts the dialog renders its picker only — it never
/// completes the move, so shared site state is untouched.
/// </summary>
[Collection("Playwright")]
public class M9SurfaceTests
{
private readonly PlaywrightFixture _pw;
public M9SurfaceTests(PlaywrightFixture pw)
{
_pw = pw;
}
/// <summary>
/// Type a unique needle into the Templates page search box and confirm
/// <see cref="TemplateFolderTree"/> prunes the tree: the matching template's label is
/// shown while a sibling throwaway template (different unique suffix) is removed from the
/// DOM. The filter rebuilds <c>_visibleRoots</c> from matches only — non-matches are gone,
/// not CSS-hidden — so a <c>ToHaveCountAsync(0)</c> on the non-match is the correct assertion.
/// </summary>
[SkippableFact]
public async Task TemplateSearch_FiltersTree()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
// Two root templates with disjoint unique names. We search for `matchName` and assert
// `otherName` is filtered out — both share the zztest- prefix but differ past it, so a
// substring search on the full match name cannot also match the other.
var matchName = CliRunner.UniqueName("tmplmatch");
var otherName = CliRunner.UniqueName("tmplother");
var matchId = await CliRunner.CreateTemplateAsync(matchName);
var otherId = await CliRunner.CreateTemplateAsync(otherName);
try
{
var page = await _pw.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Assertions.Expect(page.Locator("h4:has-text('Templates')")).ToBeVisibleAsync();
// Both templates are root nodes, so both labels are present before filtering.
await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = matchName }))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
// Type the full match name into the search box (placeholder "Search templates...").
// @oninput drives _searchText → TemplateFolderTree.Filter → ApplyFilter on each keystroke.
await page.Locator("input[placeholder='Search templates...']").FillAsync(matchName);
// The match remains; the other template is pruned out of the tree entirely.
await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = matchName }))
.ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
// Clearing the search via the ✕ button restores the full tree (both labels back).
await page.Locator("button[title='Clear search']").ClickAsync();
await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
.ToBeVisibleAsync(new() { Timeout = 10_000 });
}
finally
{
await CliRunner.DeleteTemplateAsync(matchId);
await CliRunner.DeleteTemplateAsync(otherId);
}
}
/// <summary>
/// Full UI round-trip on the Schema Library page: open the editor with "New Schema", save a
/// uniquely-named library schema (seeded with the page's default object schema), confirm it
/// lands in the table and renders its <c>lib:Name</c> reference, then delete it via the row's
/// Delete button + the global DialogHost confirm. Doubles as the page render-smoke: the page,
/// the SchemaBuilder editor, and the success/delete toasts all render with no circuit crash.
/// The DB safety-net <c>finally</c> only fires if the UI delete failed mid-flow.
/// </summary>
[SkippableFact]
public async Task SchemaLibrary_CreateDelete_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
// Schema names allow only a constrained identifier set, and the CLI has no schema
// command, so build a unique-but-valid name from the GUID suffix (letters/digits only).
var name = "Zztest" + Guid.NewGuid().ToString("N")[..10];
var page = await _pw.NewAuthenticatedPageAsync();
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/schema-library");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// ── RENDER-SMOKE ──────────────────────────────────────────────────────────────
// The page heading + New Schema button must render (no circuit crash on load).
await Assertions.Expect(page.Locator("h4:has-text('Schema Library')")).ToBeVisibleAsync();
await page.ClickAsync("button.btn.btn-primary.btn-sm:has-text('New Schema')");
// The editor card opens with the SchemaBuilder; its Name input is now visible.
// (If the SchemaBuilder threw on render, the circuit would tear down and this fails.)
var nameInput = page.Locator("input[placeholder='e.g. Address']");
await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 10_000 });
// ── CREATE ────────────────────────────────────────────────────────────────────
await nameInput.FillAsync(name);
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
// Success toast fires and the row appears in the table with its lib: reference.
await Assertions.Expect(page.Locator(".toast", new() { HasText = "created" }))
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
await Assertions.Expect(page.Locator($"td.fw-semibold:has-text('{name}')"))
.ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(page.Locator($"td code:has-text('lib:{name}')"))
.ToBeVisibleAsync(new() { Timeout = 10_000 });
// ── DELETE ────────────────────────────────────────────────────────────────────
// Scope the Delete click to this schema's own row so we never click a sibling's button.
var row = page.Locator("tr").Filter(new() { Has = page.Locator($"td.fw-semibold:has-text('{name}')") });
await row.Locator("button.btn-outline-danger:has-text('Delete')").ClickAsync();
// Global DialogHost confirm modal (has `fade`); the danger button is labelled "Delete".
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
await confirmBtn.ClickAsync();
// The row is gone after delete.
await Assertions.Expect(page.Locator($"td.fw-semibold:has-text('{name}')"))
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
}
finally
{
// Best-effort DB safety-net: the happy path deletes via the UI, so this finds
// nothing; it only removes an orphan if the UI delete failed mid-flow. There is no
// CLI schema command, so this is a direct DELETE on the uniquely-named row.
await TryDeleteSchemaByNameAsync(name);
}
}
/// <summary>
/// CLI-seed a throwaway connection on site-a, open its kebab → "Move to Site…", and assert the
/// MoveDataConnectionDialog opens with its site <c>&lt;select&gt;</c> picker rendered. This does
/// NOT submit the move — the dialog is cancelled — so no shared site state is mutated. Two-plus
/// sites exist on the live cluster, so the picker (rather than the "no other site" fallback)
/// renders.
/// </summary>
[SkippableFact]
public async Task MoveConnectionDialog_OpensWithSitePicker()
{
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.
var bulkDropdown = page.Locator(".dropdown")
.Filter(new() { Has = page.Locator("button.dropdown-toggle:has-text('Bulk actions')") });
await bulkDropdown.Locator("button.dropdown-toggle:has-text('Bulk actions')").ClickAsync();
await bulkDropdown.Locator(".dropdown-menu .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 });
// Scope all dropdown interaction to THIS node's own .dc-node-actions wrapper, keyed by
// the unique aria-label (mirrors DataConnectionCrudTests' strict-mode-safe approach).
var nodeActions = page.Locator(".dc-node-actions")
.Filter(new() { Has = page.Locator($"button[aria-label='More actions for {name}']") });
await node.HoverAsync();
await nodeActions.Locator("button.dc-kebab").ClickAsync(new() { Force = true });
await nodeActions.Locator(".dropdown-menu .dropdown-item:has-text('Move to Site…')")
.ClickAsync();
// The DialogHost custom dialog opens with the move title and the site picker select.
await Assertions.Expect(page.Locator($".modal-title:has-text(\"Move '{name}' to site…\")"))
.ToBeVisibleAsync(new() { Timeout = 10_000 });
var picker = page.Locator(".modal-body select.form-select");
await Assertions.Expect(picker).ToBeVisibleAsync(new() { Timeout = 10_000 });
// The picker must offer at least one candidate site (site-b/site-c exist on the live
// cluster), proving the picker — not the "No other site is available" fallback — rendered.
Assert.True(await picker.Locator("option").CountAsync() >= 1);
// Cancel — DO NOT submit. No shared site state is mutated.
await page.Locator(".modal-footer .btn-outline-secondary:has-text('Cancel')").ClickAsync();
}
finally
{
await CliRunner.DeleteDataConnectionAsync(id);
}
}
/// <summary>
/// Best-effort direct DELETE of a library schema by name, for teardown. There is no CLI
/// schema command, so the test owns this fallback. Swallows every failure so it can never
/// mask the original test outcome.
/// </summary>
private static async Task TryDeleteSchemaByNameAsync(string name)
{
try
{
await using var conn = new SqlConnection(PlaywrightDbConnection.ConnectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM SharedSchemas WHERE Name = @name;";
cmd.Parameters.AddWithValue("@name", name);
await cmd.ExecuteNonQueryAsync();
}
catch
{
// Best-effort — never fail (or mask) the test on teardown of a uniquely-named row.
}
}
}