Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs
T
Joseph Doherty 52726cf30d fix(central-ui): Debug View reconnect guards the stored instance; drop select-era sort; deep-link-in-area test
Three final-review findings on the two picker pages:

1. DebugView's localStorage auto-reconnect restored the stored instance id with
   no membership check, where the ?instanceId= path has one — a stored instance
   that is no longer Enabled (or was deleted, or moved sites) is filtered out of
   the list the picker is handed, so the field rendered blank while the page held
   a non-zero id and auto-connected to it anyway. The guard now mirrors the
   deep-link path: forget the stored instance id, tell the operator, and leave the
   site selected and loaded so they can pick again.
2. FlowDebugger's _instances kept an OrderBy(UniqueName) from the flat <select>
   era; InstanceTreeBuilder sorts areas and instances itself.
3. The deep-link test is joined by one where the instance sits two areas deep, so
   the field text is pinned to resolve from the flat list with the panel never
   opened.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 10:01:22 -04:00

232 lines
9.8 KiB
C#

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;
/// <summary>
/// bUnit tests for the Debug View's instance selector after it moved from a flat
/// <c>&lt;select&gt;</c> to the shared <c>InstancePicker</c> 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 <em>Connect</em>, and that a live connection
/// locks the picker down.
/// </summary>
public class DebugViewInstancePickerTests : BunitContext
{
private const int SiteId = 1;
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private void RegisterServices()
{
// DebugView touches localStorage on render; let bUnit answer loosely.
JSInterop.Mode = JSRuntimeMode.Loose;
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(new List<Instance>
{
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<Area>
{
new("Line1") { Id = 1, SiteId = SiteId },
new("Cell A") { Id = 2, SiteId = SiteId, ParentAreaId = 1 },
});
Services.AddSingleton(_repo);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>
{
new("Site One", "site-a") { Id = SiteId },
});
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.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<DebugStreamService>.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<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
/// <summary>Renders the page, selects the seeded site and opens the picker panel.</summary>
private IRenderedComponent<DebugViewPage> RenderWithOpenPicker()
{
RegisterServices();
var cut = Render<DebugViewPage>();
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<DebugViewPage> 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<DebugViewPage>();
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"));
}
/// <summary>
/// The picker must stay disabled for the WHOLE site load, not just until the site id lands.
/// Blazor renders as soon as <c>LoadInstancesForSite</c> hits its first await, and at that point
/// the page is not <c>_connected</c> — so the field was already enabled while the site's lists
/// were still loading. Typing into it then (an operator, or Playwright's <c>FillAsync</c>, 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.
/// </summary>
[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<IReadOnlyList<Instance>>();
_repo.GetInstancesBySiteIdAsync(SiteId).Returns(gate.Task);
var cut = Render<DebugViewPage>();
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")));
}
/// <summary>
/// The localStorage auto-reconnect must apply the same membership guard the
/// <c>?instanceId=</c> 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.
/// </summary>
[Fact]
public void AutoReconnect_FromStorage_SkipsAnInstanceThatIsNoLongerAvailable()
{
RegisterServices();
JSInterop.Setup<string>("localStorage.getItem", "debugView.siteId").SetResult(SiteId.ToString());
JSInterop.Setup<string>("localStorage.getItem", "debugView.instanceId").SetResult("8");
var cut = Render<DebugViewPage>();
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<DebugViewPage>();
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"));
}
}