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;
///
/// Covers the M7-B6 (T15/T16) additions to NodeBrowserDialog: the
/// address-space search box that renders
/// 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
/// OnSelected callback fires the chosen node id on confirm).
///
public class NodeBrowserDialogSearchTests : BunitContext
{
private readonly IBrowseService _browse = Substitute.For();
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(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any())
.Returns(new BrowseNodeResult(Array.Empty(), 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 RenderShown(out string? selected)
{
string? captured = null;
var cut = Render(p => p
.Add(c => c.SiteId, "plant-a")
.Add(c => c.ConnectionName, "PLC-OPC")
.Add(c => c.OnSelected, EventCallback.Factory.Create(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 _capturedSelection = () => null;
[Fact]
public void Search_RendersMatchRows_WithDataTestHooks()
{
_browse.SearchAsync(
Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.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(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.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] a")[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(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.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(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(new SearchAddressSpaceResult(
Matches: Array.Empty(),
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"));
}
}