9e243493fb
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.
341 lines
16 KiB
C#
341 lines
16 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
|
|
|
/// <summary>
|
|
/// End-to-end round-trip for the Template design pages:
|
|
/// create → add attribute → delete, all via the Central UI against the
|
|
/// running dev cluster.
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
|
{
|
|
private readonly PlaywrightFixture _fixture;
|
|
private readonly DeploymentFixture _cluster;
|
|
|
|
public TemplateCrudTests(PlaywrightFixture fixture, DeploymentFixture cluster)
|
|
{
|
|
_fixture = fixture;
|
|
_cluster = cluster;
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateAddAttributeDelete_Template_RoundTrips()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var name = $"zztest-tmpl-{Guid.NewGuid().ToString("N")[..8]}";
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE ────────────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Name input is label-anchored: <label class="form-label">Name</label>
|
|
// followed by <input class="form-control" @bind="_createName" />.
|
|
// Use the label's sibling input scoped to the containing div to avoid any
|
|
// strict-mode violation from the Description input (also form-control).
|
|
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
|
|
|
// Leave Parent Template at the default "(None - root template)".
|
|
// Click the green Create button.
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Create')");
|
|
|
|
// After a successful create, Blazor navigates to /design/templates/{id}.
|
|
// Poll window.location until the path matches /design/templates/ + digits
|
|
// and does not still say /create.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates/", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Sanity: we must be on a numeric template detail URL.
|
|
Assert.Matches(@"/design/templates/\d+$", page.Url);
|
|
|
|
// ── ADD ATTRIBUTE ─────────────────────────────────────────────────────────
|
|
// The Attributes tab is the default-active tab (_activeTab = "attributes"),
|
|
// so we don't need to click it, but we do wait for the tab panel to render.
|
|
await Assertions.Expect(
|
|
page.Locator("button.nav-link:has-text('Attributes')"))
|
|
.ToBeVisibleAsync();
|
|
|
|
// Click Add Attribute.
|
|
await page.ClickAsync("button.btn.btn-primary.btn-sm:has-text('Add Attribute')");
|
|
|
|
// The modal is a page-local .modal.show.d-block — NOT the global DialogHost.
|
|
// DialogHost adds a `fade` class; the page-local modal does not, so :not(.fade)
|
|
// ensures we match only the page-local Add-Attribute modal.
|
|
var modal = page.Locator(".modal.show.d-block:not(.fade)");
|
|
await Assertions.Expect(modal).ToBeVisibleAsync();
|
|
await Assertions.Expect(modal.Locator(".modal-title")).ToHaveTextAsync("Add Attribute");
|
|
|
|
// Fill Name field inside the modal.
|
|
await modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control").FillAsync("Val");
|
|
|
|
// Select Data Type = Double.
|
|
// The select is label-anchored: <label>Data Type</label> + <select class="form-select">.
|
|
await modal.Locator("div.col-12:has(label:has-text('Data Type')) select.form-select").SelectOptionAsync("Double");
|
|
|
|
// Click the footer Add button (btn-success, text "Add"). The dialog feet were
|
|
// wrapped in a btn-group btn-group-sm by the 2026-08-11 UI sweep, so the size
|
|
// class now lives on the group rather than on the button itself.
|
|
await modal.Locator(".modal-footer button.btn-success:has-text('Add')").ClickAsync();
|
|
|
|
// The modal should dismiss and the attribute table should show "Val".
|
|
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
await Assertions.Expect(page.Locator("table td:has-text('Val')"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
|
|
// ── DELETE ────────────────────────────────────────────────────────────────
|
|
// Click the header Delete button (btn-outline-danger btn-sm, text "Delete").
|
|
await page.ClickAsync("button.btn.btn-outline-danger:has-text('Delete')");
|
|
|
|
// The global DialogHost renders a confirm dialog.
|
|
// The danger confirm button carries class "btn-danger" and text "Delete".
|
|
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
|
|
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
|
await confirmBtn.ClickAsync();
|
|
|
|
// After delete, Blazor navigates back to /design/templates (the list page).
|
|
// excludePath: "/design/templates/" rejects any /design/templates/{id} detail URL.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates", excludePath: "/design/templates/");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The template name must no longer appear in the tree view.
|
|
await Assertions.Expect(
|
|
page.Locator("span.tv-label", new() { HasText = name }))
|
|
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort safety net: clean up any leftover zztest-tmpl-* templates
|
|
// that were not deleted by the UI (e.g. test failed mid-flow).
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateTemplate_DuplicateName_ShowsInlineError()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// CLI-seed an existing base template, then UI-attempt to create a duplicate.
|
|
// Base (non-derived) Template.Name has a unique index
|
|
// (HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived]=0")) and
|
|
// TemplateService.CreateTemplateAsync has no friendly duplicate pre-check,
|
|
// so the DB-constraint exception is caught into _formError and rendered inline
|
|
// in div.text-danger.small with no navigation (stays on /create).
|
|
// (Empirically confirmed: duplicate create surfaces inline; duplicate-name path used.)
|
|
var name = CliRunner.UniqueName("tmpl");
|
|
var seededId = await CliRunner.CreateTemplateAsync(name);
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill the Name input with the duplicate name and click Create.
|
|
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Create')");
|
|
|
|
// Web-first assertions: the inline error becomes visible and we stay on /create.
|
|
// Do NOT assert a literal message — it is the DB-constraint exception text.
|
|
await Assertions.Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/design/templates/create"));
|
|
}
|
|
finally
|
|
{
|
|
// Delete the seeded source template, then sweep any leftover by name.
|
|
await CliRunner.DeleteTemplateAsync(seededId);
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateTemplate_Cancel_ReturnsToListWithoutCreating()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var name = CliRunner.UniqueName("tmpl");
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill the Name input, then click Cancel — Blazor navigates back to the list.
|
|
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
|
await page.ClickAsync("button.btn.btn-outline-secondary:has-text('Cancel')");
|
|
|
|
// excludePath: "/create" rejects the /design/templates/create URL we came from.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Nothing was created: no template exists with our unique name.
|
|
Assert.Empty(await CliRunner.ListTemplateIdsByNamePrefixAsync(name));
|
|
}
|
|
finally
|
|
{
|
|
// Defensive sweep by name in case of an unexpected create.
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task EditAttribute_PersistsChange()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// CLI-seed a template with a single Double "Val" attribute, then edit its
|
|
// Value through the page-local Edit-Attribute modal and confirm it persists.
|
|
var name = CliRunner.UniqueName("tmpl");
|
|
var id = await CliRunner.CreateTemplateAsync(name);
|
|
await CliRunner.AddAttributeAsync(id, "Val", "Double");
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{id}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Open the Val row's actions dropdown, then click the "Edit…" item
|
|
// (note the ellipsis char "…" — it is in the markup verbatim).
|
|
await page.ClickAsync("button[aria-label=\"More actions for Val\"]");
|
|
await page.ClickAsync("button.dropdown-item:has-text('Edit…')");
|
|
|
|
// The page-local attribute modal is .modal.show.d-block WITHOUT `fade`
|
|
// (the DialogHost confirm modal HAS `fade`). :not(.fade) pins the
|
|
// page-local one.
|
|
var modal = page.Locator(".modal.show.d-block:not(.fade)");
|
|
await Assertions.Expect(modal).ToBeVisibleAsync();
|
|
await Assertions.Expect(modal.Locator("h6.modal-title")).ToHaveTextAsync("Edit Attribute");
|
|
|
|
// When editing, the Name input is rendered readonly (readonly="@editing").
|
|
await Assertions.Expect(
|
|
modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control"))
|
|
.ToHaveAttributeAsync("readonly", "");
|
|
|
|
// The Value input is label-anchored: its containing div carries the
|
|
// "Value" label. ("Data Source Ref" does not contain "Value", so the
|
|
// substring match resolves uniquely to the Value field.)
|
|
var valueInput = modal.Locator("div.col-12:has(label:has-text('Value')) input.form-control");
|
|
await valueInput.FillAsync("42.5");
|
|
|
|
// Footer button text is "Save" when editing (vs "Add" when adding). Size class
|
|
// sits on the enclosing btn-group-sm since the 2026-08-11 UI sweep.
|
|
await modal.Locator(".modal-footer button.btn-success:has-text('Save')").ClickAsync();
|
|
|
|
// SaveAttribute persists then reloads, dismissing the modal.
|
|
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
|
|
// The attribute table renders the value in a <td class="small">@effectiveValue</td>
|
|
// cell (for a non-derived template effectiveValue == attr.Value), so the
|
|
// persisted "42.5" must appear in the Val row's value cell. Web-first wait
|
|
// covers the post-save reload.
|
|
await Assertions.Expect(page.Locator("table td.small:has-text('42.5')"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
// Defensive sweep by name in case the delete above was a no-op.
|
|
try
|
|
{
|
|
foreach (var leftover in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(leftover);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task DeleteTemplate_WithInstance_IsBlocked()
|
|
{
|
|
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
|
|
|
// Mint a non-deployed instance referencing the fixture's template — that
|
|
// reference is sufficient for TemplateDeletionService to block the delete
|
|
// with the "instance(s) reference it" error.
|
|
var (instId, _) = await _cluster.CreateInstanceAsync();
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{_cluster.TemplateId}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Header Delete button (btn-outline-danger btn-sm).
|
|
await page.ClickAsync("button.btn.btn-outline-danger:has-text('Delete')");
|
|
|
|
// Confirm via the global DialogHost modal's danger button (labelled
|
|
// "Delete"). This modal HAS the `fade` class — distinct from the
|
|
// page-local attribute modal.
|
|
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
|
|
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
|
await confirmBtn.ClickAsync();
|
|
|
|
// The delete fails: DeleteTemplate surfaces result.Error on a toast and
|
|
// does NOT navigate. The error reads
|
|
// "Cannot delete template '{name}': {n} instance(s) reference it (...)".
|
|
await Assertions.Expect(page.Locator(".toast"))
|
|
.ToContainTextAsync("instance(s) reference it", new() { Timeout = 10_000 });
|
|
|
|
// The template still exists — the page stayed on the detail URL because
|
|
// the failed delete did not navigate away.
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex(
|
|
$"/design/templates/{_cluster.TemplateId}$"));
|
|
}
|
|
finally
|
|
{
|
|
// Remove this test's instance so the fixture template is instance-free
|
|
// again; the fixture's DisposeAsync sweeps the template + any leftovers.
|
|
await CliRunner.DeleteInstanceAsync(instId);
|
|
}
|
|
}
|
|
}
|