diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor index eb1f8247..6a20f5ff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -82,14 +82,10 @@
- +
@if (!_connected) @@ -257,6 +253,11 @@ private List _sites = new(); private List _siteInstances = new(); + + // The site's areas, backing the InstancePicker's tree. Both lists are REPLACED + // (never mutated in place) on a site change — the picker compares them by + // reference and treats a new object as "the site changed". + private List _siteAreas = new(); private int _selectedSiteId; private int _selectedInstanceId; private bool _loading = true; @@ -429,7 +430,8 @@ private async Task LoadInstancesForSite() { - _siteInstances.Clear(); + _siteInstances = new(); + _siteAreas = new(); _selectedInstanceId = 0; if (_selectedSiteId == 0) return; // Site scoping (CentralUI-002): re-check the claim server-side — a query @@ -445,6 +447,7 @@ _siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) .Where(i => i.State == InstanceState.Enabled) .ToList(); + _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); } catch (Exception ex) { @@ -452,6 +455,13 @@ } } + /// Handles a pick from the InstancePicker combobox. + private void OnInstancePicked(int id) + { + _selectedInstanceId = id; + OnInstanceSelectionChanged(); + } + private void OnInstanceSelectionChanged() { // No-op; selection is tracked via _selectedInstanceId binding @@ -597,7 +607,8 @@ await Disconnect(); _selectedSiteId = 0; _selectedInstanceId = 0; - _siteInstances.Clear(); + _siteInstances = new(); + _siteAreas = new(); _toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs new file mode 100644 index 00000000..43cee11c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs @@ -0,0 +1,156 @@ +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, +/// and that the selection still enables Connect. +/// +public class DebugViewInstancePickerTests : BunitContext +{ + private const int SiteId = 1; + + private void RegisterServices() + { + // DebugView touches localStorage on render; let bUnit answer loosely. + JSInterop.Mode = JSRuntimeMode.Loose; + + var repo = Substitute.For(); + 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")); + } +}