using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.CentralUI.Components.Shared; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using DataConnectionsPage = ScadaLink.CentralUI.Components.Pages.Design.DataConnections; namespace ScadaLink.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 { private readonly ISiteRepository _siteRepo = Substitute.For(); public DataConnectionsPageTests() { Services.AddSingleton(_siteRepo); // Satisfy the page's [Inject] IDialogService — the host that actually // renders the dialog lives in MainLayout, not in bUnit's render scope. Services.AddScoped(); AddTestAuth(); JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); JSInterop.SetupVoid("treeviewStorage.save", _ => true); } private void AddTestAuth() { var claims = new[] { new Claim("Username", "tester"), new Claim(ClaimTypes.Role, "Admin") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); } 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"); [Fact] public void Renders_EmptyState_WhenNoSites() { SeedRepos(); var cut = Render(); Assert.Contains("No sites configured", cut.Markup); } [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); } [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); } [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")); } [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")); } [Fact] public void LegacyDataConnectionsRoute_IsDeclaredOnListPage() { // Old bookmarks to /admin/data-connections must still resolve. var routes = typeof(DataConnectionsPage).GetCustomAttributes( typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false) .Cast() .Select(a => a.Template) .ToList(); Assert.Contains("/admin/connections", routes); Assert.Contains("/admin/data-connections", routes); } }