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