using Microsoft.JSInterop; using System.Security.Claims; using ZB.MOM.WW.ScadaBridge.Security; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; 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 DataConnectionsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.DataConnections; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// /// bUnit rendering tests for the Connections page (Site → DataConnection tree). /// Focuses on the Topology-style behaviors layered onto this page: always-show-empty /// sites, search dimming, toolbar gating, and dual-route declaration. /// public class DataConnectionsPageTests : BunitContext { /// Verifies the case: storage Save Fixture, completes Invocation Immediately. [Fact] public void StorageSaveFixture_CompletesInvocationImmediately() { var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]"); Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal."); } private readonly ISiteRepository _siteRepo = Substitute.For(); // The design page now injects IConnectionHealthQueryService to render // per-connection live health badges. A substitute defaulting to an empty map // keeps every existing test green (no badge / unknown state when no report). private readonly IConnectionHealthQueryService _healthQuery = Substitute.For(); // The page now opens a Move-to-Site dialog that dispatches the move // through IDataConnectionMoveService (the guard-running ManagementActor path). // Substituted so the page renders without a real ManagementActor in scope. private readonly IDataConnectionMoveService _moveService = Substitute.For(); /// Initializes a new instance of the class. public DataConnectionsPageTests() { Services.AddSingleton(_siteRepo); Services.AddSingleton(_healthQuery); Services.AddSingleton(_moveService); // Satisfy the page's [Inject] IDialogService — the host that actually // renders the dialog lives in MainLayout, not in bUnit's render scope. Services.AddScoped(); // The page now filters its site/connection tree through // SiteScopeService, so it must be resolvable in every render test. Services.AddScoped(); AddTestAuth(); // Default: no health known. Tests that assert badge state override this. _healthQuery.GetConnectionHealthAsync(Arg.Any()) .Returns(Task.FromResult>( new Dictionary())); JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); } private void AddTestAuth() { var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Administrator") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); Services.AddTestConfigurationMutations(); } private void SeedRepos( IEnumerable? sites = null, IEnumerable? connections = null) { _siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(sites?.ToList() ?? new List())); _siteRepo.GetAllDataConnectionsAsync(Arg.Any()) .Returns(Task.FromResult>(connections?.ToList() ?? new List())); } private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent cut, string label) => cut.FindAll(".tv-row") .FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label) ?.QuerySelector(".tv-toggle"); /// Verifies the case: renders, empty State, when No Sites. [Fact] public void Renders_EmptyState_WhenNoSites() { SeedRepos(); var cut = Render(); Assert.Contains("No sites configured", cut.Markup); } /// Verifies the case: renders, empty Site, as Top Level Node. [Fact] public void Renders_EmptySite_AsTopLevelNode() { // A site with no connections must still appear so it can be right-clicked // to "Add Connection here". SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }); var cut = Render(); Assert.Contains("Plant-A", cut.Markup); } /// Verifies the case: renders, site Connection, nesting. [Fact] public void Renders_SiteConnection_Nesting() { SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } }); var cut = Render(); FindToggleForLabel(cut, "Plant-A")!.Click(); Assert.Contains("PLC-1", cut.Markup); Assert.Contains("OpcUa", cut.Markup); } /// Verifies the case: search, dims Non Matches, preserves Shape. [Fact] public void Search_DimsNonMatches_PreservesShape() { SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 }, new Site("Plant-B", "plant-b") { Id = 2 } }, connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 }, new DataConnection("RTU-9", "Custom", 2) { Id = 200 } }); var cut = Render(); var search = cut.Find("input[type='text']"); search.Input("Plant-A"); // Both sites remain in the DOM (shape preserved). Plant-B gets the dim style. Assert.Contains("Plant-A", cut.Markup); Assert.Contains("Plant-B", cut.Markup); var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']"); Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Plant-B")); } /// Verifies the case: add Connection Button, disabled Until Site Selected. [Fact] public void AddConnectionButton_DisabledUntilSiteSelected() { SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }); var cut = Render(); var addBtn = cut.FindAll("button") .First(b => b.TextContent.Contains("+ Connection")); Assert.True(addBtn.HasAttribute("disabled")); // Click the site content (TreeView wires selection on .tv-content). var siteContent = cut.FindAll(".tv-row") .First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A") .QuerySelector(".tv-content")!; siteContent.Click(); var addBtnAfter = cut.FindAll("button") .First(b => b.TextContent.Contains("+ Connection")); Assert.False(addBtnAfter.HasAttribute("disabled")); } /// Verifies the case: data Connections Routes, are Declared On List Page. [Fact] public void DataConnectionsRoutes_AreDeclaredOnListPage() { // The page moved from Admin to Design; both the canonical // /design/connections route and the /design/data-connections alias // must resolve to the list page. var routes = typeof(DataConnectionsPage).GetCustomAttributes( typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false) .Cast() .Select(a => a.Template) .ToList(); Assert.Contains("/design/connections", routes); Assert.Contains("/design/data-connections", routes); } /// Verifies the case: connection Node, renders Live Health Badge, matching Status. [Fact] public void ConnectionNode_RendersLiveHealthBadge_MatchingStatus() { // A connection whose live health is Connected gets the success // health badge next to its node; a Disconnected one gets the danger badge. SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 }, new DataConnection("RTU-9", "Custom", 1) { Id = 200 } }); _healthQuery.GetConnectionHealthAsync(Arg.Any()) .Returns(Task.FromResult>( new Dictionary { [100] = ConnectionHealth.Connected, [200] = ConnectionHealth.Disconnected, })); var cut = Render(); FindToggleForLabel(cut, "Plant-A")!.Click(); // Each connection node carries a status badge keyed by connection id so the // assertion targets the right node regardless of tree ordering. var connectedBadge = cut.Find("[data-test='conn-health-100']"); Assert.Contains("bg-success", connectedBadge.GetAttribute("class")); Assert.Contains("Connected", connectedBadge.TextContent); var disconnectedBadge = cut.Find("[data-test='conn-health-200']"); Assert.Contains("bg-danger", disconnectedBadge.GetAttribute("class")); Assert.Contains("Disconnected", disconnectedBadge.TextContent); } /// Verifies the case: connection Node, has, move To Site Action. [Fact] public void ConnectionNode_Has_MoveToSiteAction() { // Each connection node exposes a "Move to Site…" action alongside // Edit/Delete. SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 }, new Site("Plant-B", "plant-b") { Id = 2 } }, connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } }); var cut = Render(); FindToggleForLabel(cut, "Plant-A")!.Click(); Assert.Contains(cut.FindAll("button"), b => b.TextContent.Contains("Move to Site")); } /// Verifies the case: move To Site, opens Dialog, excluding Current Site. [Fact] public void MoveToSite_OpensDialog_ExcludingCurrentSite() { // Clicking "Move to Site…" opens the move dialog whose target-site // picker omits the connection's current site (Plant-A) and lists the others. SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 }, new Site("Plant-B", "plant-b") { Id = 2 }, new Site("Plant-C", "plant-c") { Id = 3 } }, connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } }); // T33b: the move dialog is now opened via IDialogService.ShowAsync and rendered // by the shared DialogHost (which lives in MainLayout in production). Render a // host in the same DI scope so the dialog the page opens is displayed; the host's // focus-trap JS interop runs as no-ops under loose mode. JSInterop.Mode = JSRuntimeMode.Loose; var host = Render(); var cut = Render(); FindToggleForLabel(cut, "Plant-A")!.Click(); cut.FindAll("button").First(b => b.TextContent.Contains("Move to Site")).Click(); host.Render(); // The dialog renders with a site picker; the current site is excluded. var dialog = host.Find(".modal.show"); var optionLabels = dialog.QuerySelectorAll("select option") .Select(o => o.TextContent).ToList(); Assert.Contains(optionLabels, l => l.Contains("Plant-B")); Assert.Contains(optionLabels, l => l.Contains("Plant-C")); Assert.DoesNotContain(optionLabels, l => l.Contains("Plant-A")); } // ── site scoping. This page reads ISiteRepository directly, // bypassing ManagementActor's scope gate, and Designer is NOT exempt from // site scope — so a scoped Designer used to see every site's connections. ── /// Verifies the case: site Scoping, scoped Designer, sees Only Permitted Sites And Connections. [Fact] public void SiteScoping_ScopedDesigner_SeesOnlyPermittedSitesAndConnections() { SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 }, new Site("Plant-B", "plant-b") { Id = 2 } }, connections: new[] { new DataConnection("PLC-A", "OpcUa", 1) { Id = 100 }, new DataConnection("PLC-B", "OpcUa", 2) { Id = 200 } }); var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.UsernameClaimType, "scoped"), new Claim(JwtTokenService.RoleClaimType, "Designer"), new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant-A only }, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(scopedUser)); var cut = Render(); FindToggleForLabel(cut, "Plant-A")!.Click(); Assert.Contains("Plant-A", cut.Markup); Assert.Contains("PLC-A", cut.Markup); // Neither the out-of-scope site nor its connection may appear anywhere. Assert.DoesNotContain("Plant-B", cut.Markup); Assert.DoesNotContain("PLC-B", cut.Markup); } /// Verifies the case: site Scoping, system Wide User, sees Every Connection. [Fact] public void SiteScoping_SystemWideUser_SeesEveryConnection() { // The unscoped case is unchanged: the default test principal is an // Administrator (system-wide), so the filter is a no-op. SeedRepos( sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 }, new Site("Plant-B", "plant-b") { Id = 2 } }, connections: new[] { new DataConnection("PLC-A", "OpcUa", 1) { Id = 100 }, new DataConnection("PLC-B", "OpcUa", 2) { Id = 200 } }); var cut = Render(); FindToggleForLabel(cut, "Plant-A")!.Click(); FindToggleForLabel(cut, "Plant-B")!.Click(); Assert.Contains("PLC-A", cut.Markup); Assert.Contains("PLC-B", cut.Markup); } }