Merge branch 'worktree-agent-a9d87141c744e7733' into feat/instance-picker
This commit is contained in:
@@ -82,14 +82,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small">Instance</label>
|
<label class="form-label small">Instance</label>
|
||||||
<select class="form-select form-select-sm" data-test="debug-instance-select"
|
<InstancePicker Areas="_siteAreas" Instances="_siteInstances"
|
||||||
@bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
|
ItemLabel="@(i => $"{i.UniqueName} ({i.State})")"
|
||||||
<option value="0">Select instance...</option>
|
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePicked"
|
||||||
@foreach (var inst in _siteInstances)
|
Disabled="@_connected" DataTestPrefix="debug-instance" />
|
||||||
{
|
|
||||||
<option value="@inst.Id">@inst.UniqueName (@inst.State)</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 d-flex align-items-end gap-2">
|
<div class="col-md-3 d-flex align-items-end gap-2">
|
||||||
@if (!_connected)
|
@if (!_connected)
|
||||||
@@ -257,6 +253,11 @@
|
|||||||
|
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private List<Instance> _siteInstances = new();
|
private List<Instance> _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<Area> _siteAreas = new();
|
||||||
private int _selectedSiteId;
|
private int _selectedSiteId;
|
||||||
private int _selectedInstanceId;
|
private int _selectedInstanceId;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
@@ -429,7 +430,8 @@
|
|||||||
|
|
||||||
private async Task LoadInstancesForSite()
|
private async Task LoadInstancesForSite()
|
||||||
{
|
{
|
||||||
_siteInstances.Clear();
|
_siteInstances = new();
|
||||||
|
_siteAreas = new();
|
||||||
_selectedInstanceId = 0;
|
_selectedInstanceId = 0;
|
||||||
if (_selectedSiteId == 0) return;
|
if (_selectedSiteId == 0) return;
|
||||||
// Site scoping (CentralUI-002): re-check the claim server-side — a query
|
// Site scoping (CentralUI-002): re-check the claim server-side — a query
|
||||||
@@ -445,6 +447,7 @@
|
|||||||
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
||||||
.Where(i => i.State == InstanceState.Enabled)
|
.Where(i => i.State == InstanceState.Enabled)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
_siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -452,6 +455,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Handles a pick from the <c>InstancePicker</c> combobox.</summary>
|
||||||
|
private void OnInstancePicked(int id)
|
||||||
|
{
|
||||||
|
_selectedInstanceId = id;
|
||||||
|
OnInstanceSelectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnInstanceSelectionChanged()
|
private void OnInstanceSelectionChanged()
|
||||||
{
|
{
|
||||||
// No-op; selection is tracked via _selectedInstanceId binding
|
// No-op; selection is tracked via _selectedInstanceId binding
|
||||||
@@ -597,7 +607,8 @@
|
|||||||
await Disconnect();
|
await Disconnect();
|
||||||
_selectedSiteId = 0;
|
_selectedSiteId = 0;
|
||||||
_selectedInstanceId = 0;
|
_selectedInstanceId = 0;
|
||||||
_siteInstances.Clear();
|
_siteInstances = new();
|
||||||
|
_siteAreas = new();
|
||||||
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
|
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+156
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for the Debug View's instance selector after it moved from a flat
|
||||||
|
/// <c><select></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,
|
||||||
|
/// and that the selection still enables <em>Connect</em>.
|
||||||
|
/// </summary>
|
||||||
|
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<ITemplateEngineRepository>();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user