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