using System.Reflection; using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// /// bUnit tests for the Debug View's instance selector after it moved from a flat /// <select> to the shared InstancePicker area-tree combobox /// (design 2026-09-08). Pins that the page hands the picker the site's areas and /// its Enabled-only instance list, that drilling down the tree selects an instance, /// that the selection still enables Connect, and that a live connection /// locks the picker down. /// public class DebugViewInstancePickerTests : BunitContext { private const int SiteId = 1; private readonly ITemplateEngineRepository _repo = Substitute.For(); private void RegisterServices() { // DebugView touches localStorage on render; let bUnit answer loosely. JSInterop.Mode = JSRuntimeMode.Loose; _repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List { new("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }, new("Off01") { Id = 8, SiteId = SiteId, State = InstanceState.Disabled }, }); _repo.GetAreasBySiteIdAsync(SiteId).Returns(new List { new("Line1") { Id = 1, SiteId = SiteId }, new("Cell A") { Id = 2, SiteId = SiteId, ParentAreaId = 1 }, }); Services.AddSingleton(_repo); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync().Returns(new List { new("Site One", "site-a") { Id = SiteId }, }); Services.AddSingleton(siteRepo); var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); Services.AddSingleton(comms); var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance); // An empty throwaway provider — these tests never call StartStreamAsync, // so the provider is unused. (Services.BuildServiceProvider() would leak // an undisposed provider.) var debugStream = new DebugStreamService( comms, new ServiceCollection().BuildServiceProvider(), grpcFactory, NullLogger.Instance); Services.AddSingleton(debugStream); // No SiteId claim => system-wide, so site 1 passes the scope re-check. var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); var authState = new AuthenticationState(new ClaimsPrincipal(identity)); var stubAuth = new StubAuthStateProvider(authState); Services.AddSingleton(stubAuth); Services.AddScoped(_ => new SiteScopeService(stubAuth)); } private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; public StubAuthStateProvider(AuthenticationState state) => _state = state; public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } /// Renders the page, selects the seeded site and opens the picker panel. private IRenderedComponent RenderWithOpenPicker() { RegisterServices(); var cut = Render(); cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString()); cut.Find("[data-test='debug-instance-toggle']").Click(); return cut; } private static void ClickRow(IRenderedComponent cut, string text) => cut.FindAll("[data-test='debug-instance-row']") .First(r => r.TextContent.Trim() == text) .Click(); [Fact] public void InstanceSelector_IsThePicker_NotAFlatSelect() { RegisterServices(); var cut = Render(); Assert.Single(cut.FindAll("[data-test='debug-instance-picker']")); Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']")); } [Fact] public void OpeningThePicker_ShowsOnlyTheRootArea() { var cut = RenderWithOpenPicker(); var rows = cut.FindAll("[data-test='debug-instance-row']"); Assert.Single(rows); Assert.Equal("Line1", rows[0].TextContent.Trim()); Assert.False(rows[0].HasAttribute("data-instance-id")); } [Fact] public void DrillingDown_RevealsTheEnabledInstanceOnly() { var cut = RenderWithOpenPicker(); ClickRow(cut, "Line1"); ClickRow(cut, "Cell A"); var instanceRow = cut.Find("[data-instance-id='7']"); Assert.Equal("Sim01 (Enabled)", instanceRow.TextContent.Trim()); // Off01 is Disabled, so the page filters it out before the picker sees it. Assert.DoesNotContain( cut.FindAll("[data-test='debug-instance-row']"), r => r.TextContent.Contains("Off01")); } [Fact] public void PickingAnInstance_FillsTheField_AndEnablesConnect() { var cut = RenderWithOpenPicker(); ClickRow(cut, "Line1"); ClickRow(cut, "Cell A"); cut.Find("[data-instance-id='7']").Click(); Assert.Equal( "Sim01 (Enabled)", cut.Find("[data-test='debug-instance-input']").GetAttribute("value")); var connect = cut.FindAll("button.btn.btn-primary.btn-sm") .First(b => b.TextContent.Contains("Connect")); Assert.False(connect.HasAttribute("disabled")); } /// /// The picker must stay disabled for the WHOLE site load, not just until the site id lands. /// Blazor renders as soon as LoadInstancesForSite hits its first await, and at that point /// the page is not _connected — so the field was already enabled while the site's lists /// were still loading. Typing into it then (an operator, or Playwright's FillAsync, which /// waits only for "enabled") lost the text: the picker reads the freshly-assigned lists as /// "the site changed" and clears the field and closes the panel. /// [Fact] public void PickerStaysDisabled_UntilTheSitesInstancesHaveLoaded() { RegisterServices(); // Hold the instance load open so the render that follows the first await is observable. var gate = new TaskCompletionSource>(); _repo.GetInstancesBySiteIdAsync(SiteId).Returns(gate.Task); var cut = Render(); cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString()); Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")); Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled")); cut.InvokeAsync(() => gate.SetResult( [new Instance("Sim01") { Id = 7, SiteId = SiteId, AreaId = 2, State = InstanceState.Enabled }])); cut.WaitForAssertion(() => Assert.False(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled"))); } /// /// The localStorage auto-reconnect must apply the same membership guard the /// ?instanceId= deep link does. Off01 is Disabled, so the page filters it out of the /// list the picker is handed — restoring it anyway left the field blank while the page held /// a non-zero id and auto-connected to it. The stored id is dropped instead, and the site /// stays selected and loaded so the operator can pick again. /// [Fact] public void AutoReconnect_FromStorage_SkipsAnInstanceThatIsNoLongerAvailable() { RegisterServices(); JSInterop.Setup("localStorage.getItem", "debugView.siteId").SetResult(SiteId.ToString()); JSInterop.Setup("localStorage.getItem", "debugView.instanceId").SetResult("8"); var cut = Render(); cut.WaitForAssertion(() => Assert.Equal(string.Empty, cut.Find("[data-test='debug-instance-input']").GetAttribute("value"))); // Nothing is selected, so Connect stays disabled — and the stale id is forgotten. var connect = cut.FindAll("button.btn.btn-primary.btn-sm").First(b => b.TextContent.Contains("Connect")); Assert.True(connect.HasAttribute("disabled")); JSInterop.VerifyInvoke("localStorage.removeItem"); } [Fact] public void WhileConnected_ThePickerIsDisabled() { RegisterServices(); var cut = Render(); cut.Find("[data-test='debug-site-select']").Change(SiteId.ToString()); // Mark the page connected the way the other DebugView page tests do — // driving the real gRPC stream is not available to a bUnit render. cut.InvokeAsync(() => typeof(DebugViewPage) .GetField("_connected", BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(cut.Instance, true)); cut.Render(); Assert.True(cut.Find("[data-test='debug-instance-input']").HasAttribute("disabled")); Assert.True(cut.Find("[data-test='debug-instance-toggle']").HasAttribute("disabled")); } }