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]")); } }