Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.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

156 lines
6.1 KiB
C#

using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// Covers the M7-B6 (T15/T16) additions to <c>NodeBrowserDialog</c>: the
/// address-space search box that renders <see cref="IBrowseService.SearchAsync"/>
/// matches as a flat selectable list, and that selecting a search result feeds
/// the SAME selection mechanism the tree uses (so the dialog's existing
/// <c>OnSelected</c> callback fires the chosen node id on confirm).
/// </summary>
public class NodeBrowserDialogSearchTests : BunitContext
{
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
public NodeBrowserDialogSearchTests()
{
Services.AddSingleton(_browse);
// The root load fires on ShowAsync; give it an empty (successful) result
// so the dialog renders without a failure banner and the tree is empty.
_browse.BrowseChildrenAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new BrowseNodeResult(Array.Empty<BrowseNode>(), Truncated: false, Failure: null));
}
private static SearchAddressSpaceResult TwoMatches() => new(
Matches: new[]
{
new AddressSpaceMatch(
new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
Path: "Devices/Pump1/Speed"),
new AddressSpaceMatch(
new BrowseNode("ns=2;s=Pump1.Flow", "Flow", BrowseNodeClass.Variable, HasChildren: false, DataType: "Float"),
Path: "Devices/Pump1/Flow"),
},
CapReached: false,
Failure: null);
private IRenderedComponent<NodeBrowserDialog> RenderShown(out string? selected)
{
string? captured = null;
var cut = Render<NodeBrowserDialog>(p => p
.Add(c => c.SiteId, "plant-a")
.Add(c => c.ConnectionName, "PLC-OPC")
.Add(c => c.OnSelected, EventCallback.Factory.Create<string>(this, id => captured = id)));
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
cut.Render();
// capture box is read back by the caller after assertions
_capturedSelection = () => captured;
selected = captured;
return cut;
}
private Func<string?> _capturedSelection = () => null;
[Fact]
public void Search_RendersMatchRows_WithDataTestHooks()
{
_browse.SearchAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(TwoMatches());
var cut = RenderShown(out _);
var input = cut.Find("[data-test=node-search-input]");
input.Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
var rows = cut.FindAll("[data-test=node-search-result]");
Assert.Equal(2, rows.Count);
Assert.Contains("Speed", cut.Markup);
Assert.Contains("Devices/Pump1/Speed", cut.Markup);
Assert.Contains("Double", cut.Markup);
}
[Fact]
public void ClickingSearchResult_RaisesSelectionCallback_WithNodeId()
{
_browse.SearchAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(TwoMatches());
var cut = RenderShown(out _);
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
// Click the first result's link — this drives the SAME selection
// mechanism the tree uses, so the footer Select button confirms and
// OnSelected fires.
cut.FindAll("[data-test=node-search-result] button.btn-link")[0].Click();
cut.Find(".modal-footer .btn-primary").Click();
Assert.Equal("ns=2;s=Pump1.Speed", _capturedSelection());
}
[Fact]
public void BlankQuery_ClearsResults()
{
_browse.SearchAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(TwoMatches());
var cut = RenderShown(out _);
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
Assert.Equal(2, cut.FindAll("[data-test=node-search-result]").Count);
cut.Find("[data-test=node-search-input]").Input("");
cut.Find("[data-test=node-search-button]").Click();
Assert.Empty(cut.FindAll("[data-test=node-search-result]"));
}
[Fact]
public void BlankQueryAfterFailure_ClearsStaleFailureAlert()
{
// First search returns a failure so the alert banner appears.
_browse.SearchAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new SearchAddressSpaceResult(
Matches: Array.Empty<AddressSpaceMatch>(),
CapReached: false,
Failure: new BrowseFailure(BrowseFailureKind.Timeout, "timed out")));
var cut = RenderShown(out _);
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
// The failure alert must be visible after the failed search.
Assert.NotEmpty(cut.FindAll(".alert-danger"));
// User clears the query and searches again (blank).
cut.Find("[data-test=node-search-input]").Input("");
cut.Find("[data-test=node-search-button]").Click();
// Stale failure alert must be gone.
Assert.Empty(cut.FindAll(".alert-danger"));
}
}