Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs
T

248 lines
14 KiB
C#

using System.Text.Json;
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// <summary>
/// E2E round-trip for the InstanceConfigure page's Connection Bindings panel. The
/// <see cref="InstanceConfigureFixture"/> provisions a zztest template whose single
/// bindable <c>Double</c> attribute carries a <c>DataSourceReference</c> (so it appears
/// in the bindings panel), a zztest data-connection on site-a, a zztest area, and a
/// non-deployed instance. This fact drives the page's bulk-assign UI to bind every
/// data-sourced attribute to the fixture connection, saves, and then verifies the bind
/// actually persisted via a CLI <c>instance get</c> read-back — not just the toast.
///
/// <para>
/// Selector note: the bulk select (<c>data-test='binding-bulk-select'</c>) is bound to
/// <c>_bulkConnectionId</c> (an int), and its option VALUES are connection ids while the
/// option TEXT is <c>"{name} ({protocol})"</c>. Selecting by VALUE = the connection id is
/// the robust choice (it doesn't depend on the connection's protocol suffix in the label).
/// The bulk row only renders when there is at least one data-sourced attribute AND at
/// least one site connection — both guaranteed by the fixture — so it is always present
/// here.
/// </para>
/// </summary>
[Collection("Playwright")]
public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixture>
{
private readonly PlaywrightFixture _fixture;
private readonly InstanceConfigureFixture _cfg;
public InstanceConfigureTests(PlaywrightFixture fixture, InstanceConfigureFixture cfg)
{
_fixture = fixture;
_cfg = cfg;
}
[SkippableFact]
public async Task BindAllAttributes_SavesAndPersists()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
// This is a Blazor Server page: it renders a LoadingSpinner while OnInitializedAsync
// loads the template attributes + site connections, then re-renders the bindings
// panel (the bulk select renders only once both lists are non-empty). Settle the
// initial load (NetworkIdle) and web-first wait for the bulk select before driving it,
// so the interaction never races the post-load re-render.
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var bulkSelect = page.Locator("[data-test='binding-bulk-select']");
await Assertions.Expect(bulkSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Bulk-assign every bindable attribute to the fixture connection, then Apply + Save.
// Select by VALUE (the connection id) — most robust, since the select binds _bulkConnectionId.
await bulkSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.ConnectionId.ToString() });
await page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Save Bindings" }).ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
// Verify persistence via CLI read-back (not just the toast).
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var bindings = doc.RootElement.GetProperty("connectionBindings");
var bound = bindings.EnumerateArray().Any(b =>
b.GetProperty("attributeName").GetString() == _cfg.AttributeName
&& b.GetProperty("dataConnectionId").GetInt32() == _cfg.ConnectionId);
Assert.True(bound, "Expected the Value attribute to be bound to the fixture connection after Save Bindings.");
}
/// <summary>
/// Round-trips an attribute override through the <b>Attribute Overrides</b> card. The
/// override input carries no <c>data-test</c> hook, so it is located structurally: the
/// overrides card is the one whose Save button reads "Save Overrides"; inside it, the
/// table row whose label cell holds the attribute name (<c>_cfg.AttributeName</c> = "Value")
/// owns the type=text <c>input.form-control-sm</c>. Fills a sentinel value, saves, asserts
/// exactly one toast, then verifies the override persisted via a CLI <c>instance get</c>
/// read-back (not just the toast).
/// </summary>
[SkippableFact]
public async Task SaveOverride_RoundTrips()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
// Blazor Server page renders a LoadingSpinner first; web-first wait for the overrides
// section's Save button before driving the input so we never race the post-load re-render.
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var saveOverrides = page.GetByRole(AriaRole.Button, new() { Name = "Save Overrides" });
await Assertions.Expect(saveOverrides).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Scope to the Attribute Overrides card (the one containing the "Save Overrides" button),
// pick the row whose label cell text is the attribute name, then its text input.
var overridesCard = page.Locator("div.card", new() { Has = saveOverrides });
var overrideInput = overridesCard
.GetByRole(AriaRole.Row, new() { Name = _cfg.AttributeName })
.Locator("input.form-control-sm[type='text']");
await overrideInput.FillAsync("zztest-override-42");
await saveOverrides.ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
// Verify persistence via CLI read-back (not just the toast).
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
var overrides = doc.RootElement.GetProperty("attributeOverrides");
var saved = overrides.EnumerateArray().Any(o =>
o.GetProperty("attributeName").GetString() == _cfg.AttributeName
&& o.GetProperty("overrideValue").GetString() == "zztest-override-42");
Assert.True(saved, "Expected the Value attribute override to persist after Save Overrides.");
}
/// <summary>
/// Reassigns the (initially area-less) fixture instance to the fixture area via the
/// <b>Area Assignment</b> card. Drives the existing <c>data-test='area-select'</c> hook by
/// VALUE (the area id, since the select binds the area id), clicks "Set Area", asserts one
/// toast, and verifies the new <c>areaId</c> via a CLI <c>instance get</c> read-back. This
/// mutates the shared fixture instance's area, but is independent of the other tests (each
/// gets a fresh page and asserts only on its own effect).
/// </summary>
[SkippableFact]
public async Task SetArea_RoundTrips()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var areaSelect = page.Locator("[data-test='area-select']");
await Assertions.Expect(areaSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Select by VALUE = the area id (the select binds _reassignAreaId).
await areaSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.AreaId.ToString() });
await page.GetByRole(AriaRole.Button, new() { Name = "Set Area" }).ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
// Verify persistence: areaId must equal the fixture area after Set Area (it may have been
// null/absent before).
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
Assert.True(doc.RootElement.TryGetProperty("areaId", out var areaIdEl)
&& areaIdEl.ValueKind == JsonValueKind.Number,
"Expected areaId to be a number after Set Area.");
Assert.Equal(_cfg.AreaId, areaIdEl.GetInt32());
}
/// <summary>
/// Not-found edge: navigating to a configure URL for a non-existent instance id surfaces the
/// page's error alert (<c>data-test='instance-error-alert'</c>) carrying the
/// <c>$"Instance #{Id} not found."</c> message built in <c>InstanceConfigure.OnInitializedAsync</c>.
/// </summary>
[SkippableFact]
public async Task NotFoundInstance_ShowsErrorAlert()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/999999999/configure");
var errorAlert = page.Locator("[data-test='instance-error-alert']");
await Assertions.Expect(errorAlert).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(errorAlert).ToContainTextAsync("not found");
}
/// <summary>
/// Alarm-override round-trip on the InstanceConfigure page's <b>Alarm Overrides</b> card: Edit the
/// fixture's non-locked HiLo alarm (<c>_cfg.AlarmName</c> = "HiHi"), set a priority override, Save →
/// one toast + an "overridden" badge, then verify the <c>InstanceAlarmOverride</c> actually persisted
/// via a CLI <c>instance get</c> read-back (not just the toast/badge), then Clear → badge gone +
/// override removed (re-verified via read-back).
///
/// <para>
/// Read-back path: the <c>instance get</c> document surfaces an <c>alarmOverrides</c> array whose
/// elements are <c>{ id, instanceId, alarmCanonicalName, priorityLevelOverride }</c> (camelCase,
/// empirically verified against the dev cluster — same instance-document path the
/// <see cref="SaveOverride_RoundTrips"/> test uses for <c>attributeOverrides</c>). For a direct
/// (non-composed) alarm, <c>alarmCanonicalName</c> equals the alarm name.
/// </para>
///
/// <para>
/// Setting only the PRIORITY field is the reliable "create override" delta — a Save with no
/// config-diff AND empty priority deletes the override instead. The fixture instance is SHARED, so
/// the test clears its own override in-body (badge gone + read-back empty) and again in a
/// <c>finally</c>, leaving the instance override-free as found.
/// </para>
/// </summary>
[SkippableFact]
public async Task AlarmOverride_SetPriority_ThenClear_RoundTrips()
{
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
try
{
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The Alarm Overrides card renders one row per non-locked template alarm; web-first wait
// for the fixture alarm's row so we never race the post-load re-render. No override yet.
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
// Edit → set a priority override → Save. FillAsync fires the input event, so the priority
// input's @bind:event="oninput" commits before the Save click (no extra change dispatch).
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
var priorityInput = page.Locator("[data-test='alarm-priority-input']");
await Assertions.Expect(priorityInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
await priorityInput.FillAsync("750");
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToBeVisibleAsync(new() { Timeout = 15_000 });
// Verify the InstanceAlarmOverride persisted via CLI read-back (not just the toast/badge).
using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
{
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.Contains(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName
&& o.GetProperty("priorityLevelOverride").GetInt32() == 750);
}
// Clear is immediate (no confirm): the badge disappears and the override is removed.
await row.Locator("[data-test='alarm-clear-btn']").ClickAsync();
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
using (var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
{
var overrides = doc.RootElement.GetProperty("alarmOverrides");
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
}
}
finally
{
// Belt-and-braces: leave the shared fixture instance override-free even if an assertion
// above threw after the Save (best-effort; never masks the test's own failure).
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
}
}
}