refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
using TopologyPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Topology;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Topology page (Site → Area → Instance tree).
|
||||
/// Focuses on the behavior that's specific to this page:
|
||||
/// always-visible empty containers, search dimming, F2 area rename, and the
|
||||
/// move-dialog destination lists.
|
||||
/// </summary>
|
||||
public class TopologyPageTests : BunitContext
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
private readonly IAuditService _audit = Substitute.For<IAuditService>();
|
||||
|
||||
public TopologyPageTests()
|
||||
{
|
||||
Services.AddSingleton(_repo);
|
||||
Services.AddSingleton(_siteRepo);
|
||||
Services.AddSingleton(_deployRepo);
|
||||
Services.AddSingleton(_pipeline);
|
||||
Services.AddSingleton(_audit);
|
||||
|
||||
// DeploymentService has non-mockable concrete deps; instantiate them directly.
|
||||
var comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
Services.AddSingleton(comms);
|
||||
|
||||
Services.AddSingleton(new OperationLockManager());
|
||||
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
||||
}));
|
||||
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
|
||||
// DeploymentService gained a DiffService dependency (DeploymentManager
|
||||
// contract change); register it so the page's DI graph resolves.
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
|
||||
// CentralUI-006: DeploymentService now also depends on the
|
||||
// deployment-status notifier (a process singleton in production).
|
||||
Services.AddSingleton<ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier>(
|
||||
new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier(
|
||||
NullLogger<ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier>.Instance));
|
||||
Services.AddScoped<DeploymentService>();
|
||||
Services.AddScoped<AreaService>();
|
||||
Services.AddScoped<InstanceService>();
|
||||
|
||||
AddTestAuth();
|
||||
|
||||
// The page injects IDialogService for delete confirmations; the host
|
||||
// (rendered globally in MainLayout) is not present in bUnit, but the
|
||||
// DI registration still has to satisfy the [Inject].
|
||||
Services.AddScoped<IDialogService, DialogService>();
|
||||
|
||||
// Site scoping (CentralUI-002): Topology injects SiteScopeService to
|
||||
// filter the tree by the user's permitted sites.
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
|
||||
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
}
|
||||
|
||||
private void AddTestAuth()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment")
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
private void SeedRepos(
|
||||
IEnumerable<Site>? sites = null,
|
||||
IEnumerable<Template>? templates = null,
|
||||
IEnumerable<Instance>? instances = null,
|
||||
Dictionary<int, IReadOnlyList<Area>>? areasBySite = null)
|
||||
{
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
|
||||
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
|
||||
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));
|
||||
|
||||
areasBySite ??= new();
|
||||
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
var sid = call.Arg<int>();
|
||||
return Task.FromResult(areasBySite.TryGetValue(sid, out var list)
|
||||
? list
|
||||
: (IReadOnlyList<Area>)new List<Area>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_EmptyState_WhenNoSites()
|
||||
{
|
||||
SeedRepos();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
|
||||
Assert.Contains("No sites configured", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_EmptySite_AsTopLevelNode()
|
||||
{
|
||||
// An always-show-empty container is a hard requirement: a site with nothing
|
||||
// under it must still appear so users can move/create into it.
|
||||
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("bi-building", cut.Markup);
|
||||
}
|
||||
|
||||
private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<TopologyPage> cut, string label) =>
|
||||
cut.FindAll(".tv-row")
|
||||
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
|
||||
?.QuerySelector(".tv-toggle");
|
||||
|
||||
[Fact]
|
||||
public void Renders_SiteAreaInstance_Nesting()
|
||||
{
|
||||
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
|
||||
{
|
||||
[1] = new List<Area>
|
||||
{
|
||||
new("Line-1") { Id = 10, SiteId = 1, ParentAreaId = null }
|
||||
}
|
||||
};
|
||||
SeedRepos(
|
||||
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||
instances: new[]
|
||||
{
|
||||
new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed }
|
||||
},
|
||||
areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
|
||||
// Expand the site, then the area, to render the instance leaf. The
|
||||
// helper scopes by the row's own label so we don't match outer rows
|
||||
// whose TextContent transitively contains the inner label.
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
FindToggleForLabel(cut, "Line-1")!.Click();
|
||||
|
||||
Assert.Contains("Pump-001", cut.Markup);
|
||||
Assert.Contains("bi-diagram-3", cut.Markup);
|
||||
Assert.Contains("bi-box", cut.Markup);
|
||||
Assert.Contains("NotDeployed", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Search_DimsNonMatches_PreservesShape()
|
||||
{
|
||||
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
|
||||
{
|
||||
[1] = new List<Area>
|
||||
{
|
||||
new("Line-1") { Id = 10, SiteId = 1 },
|
||||
new("Boilers") { Id = 11, SiteId = 1 }
|
||||
}
|
||||
};
|
||||
SeedRepos(
|
||||
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||
areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var search = cut.Find("input[type='text']");
|
||||
search.Input("Line");
|
||||
|
||||
// Both areas remain in the DOM (shape preserved). 'Boilers' gets the dim style.
|
||||
Assert.Contains("Line-1", cut.Markup);
|
||||
Assert.Contains("Boilers", cut.Markup);
|
||||
var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
|
||||
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites()
|
||||
{
|
||||
// Regression test for CentralUI-002. The SiteId claims issued at login were
|
||||
// never read, so a Deployment user scoped to one site could view (and act
|
||||
// on) every site's topology. Topology now filters the tree by the user's
|
||||
// permitted sites via SiteScopeService.
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("Username", "scoped-tester"),
|
||||
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployment"),
|
||||
// Permitted on site 1 only.
|
||||
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
|
||||
}, "TestAuth"));
|
||||
// Last AuthenticationStateProvider registration wins on resolution.
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||
|
||||
SeedRepos(sites: new[]
|
||||
{
|
||||
new Site("Plant-A", "plant-a") { Id = 1 },
|
||||
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
|
||||
// The permitted site is rendered; the non-permitted site is not.
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.DoesNotContain("Plant-B", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_SystemWideDeploymentUser_SeesAllSites()
|
||||
{
|
||||
// A Deployment user with no SiteId claims is system-wide and sees every site.
|
||||
SeedRepos(sites: new[]
|
||||
{
|
||||
new Site("Plant-A", "plant-a") { Id = 1 },
|
||||
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("Plant-B", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleClick_OnAreaLabel_EntersRenameMode()
|
||||
{
|
||||
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
|
||||
{
|
||||
[1] = new List<Area> { new("Line-1") { Id = 10, SiteId = 1 } }
|
||||
};
|
||||
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
|
||||
areaLabel.DoubleClick();
|
||||
|
||||
// Inline rename input replaces the label.
|
||||
Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceRows_DoNotHaveDoubleClickRename()
|
||||
{
|
||||
// Instance rename is out of scope; the label should not have @ondblclick wired.
|
||||
// bUnit throws MissingEventHandlerException when dispatching to an element
|
||||
// that has no handler — that's the assertion: the dblclick event is not bound.
|
||||
SeedRepos(
|
||||
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||
instances: new[]
|
||||
{
|
||||
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
|
||||
Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyInstancesRoute_IsDeclaredOnTopologyPage()
|
||||
{
|
||||
// Old bookmarks to /deployment/instances must still resolve. Reflection
|
||||
// check: the Topology component carries both @page directives.
|
||||
var pageAttrs = typeof(TopologyPage).GetCustomAttributes(
|
||||
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
|
||||
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
|
||||
.Select(a => a.Template)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("/deployment/topology", pageAttrs);
|
||||
Assert.Contains("/deployment/instances", pageAttrs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user