Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewInstancePickerTests.cs
T
Joseph Doherty 82f6fb949e fix(central-ui): keep the instance picker disabled until the site's instances have loaded (typing raced the load and was wiped)
Both Flow Debugger and Debug View enable the shared InstancePicker as soon as
_selectedSiteId lands, but Blazor renders at the site-change handler's first
await — at which point the picker is still holding the PREVIOUS site's Areas /
Instances lists. Typing into the field in that window (an operator, or
Playwright's FillAsync, which waits only for "enabled") was silently lost: the
picker compares both lists by reference, so the freshly-assigned lists read as
"the site changed" and it clears the field text and closes the panel.

Both pages now hold an _instancesLoading flag across the whole load — set before
the first await, cleared in a finally so a fault or a site-scope refusal cannot
leave the picker disabled forever — and fold it into the picker's Disabled
expression. InstancePicker itself is unchanged.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 09:58:44 -04:00

207 lines
8.5 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")));
}
[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"));
}
}