Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs
T

899 lines
43 KiB
C#

using Microsoft.JSInterop;
using System.Security.Claims;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Security;
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.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
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
{
/// <summary>Verifies that the storage-save fixture completes the JavaScript invocation immediately.</summary>
[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 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>();
/// <summary>Initializes the test class with substituted repositories and the services the Topology page depends on.</summary>
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)
}));
// Notify-and-fetch: DeploymentService now also depends on
// IOptions<CommunicationOptions>; register it so the page's DI graph resolves.
Services.AddSingleton(Options.Create(new CommunicationOptions()));
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>();
// I-1: DeploymentService also recomputes the deployed-side staleness hash
// from the (List-normalized) snapshot via RevisionHashService; register it
// so the page's DI graph resolves.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
// 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));
// The bulk site deploy's up-to-date pre-filter asks the staleness
// probe for each instance's current revision hash, so DeploymentService now
// depends on it too. The page under test never bulk-deploys; a substitute
// keeps the DI graph resolvable without dragging in the flattening stack.
Services.AddSingleton(Substitute.For<IStaleInstanceProbe>());
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: 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).SetVoidResult();
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
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>()));
_repo.GetTopologyInstancesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call => (IReadOnlyList<Instance>)(instances ?? []).Where(i => i.SiteId == call.ArgAt<int>(0) && i.AreaId == call.ArgAt<int?>(1))
.Skip(call.ArgAt<int>(2)).Take(call.ArgAt<int>(3)).ToList());
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>());
});
}
private IRenderedComponent<TopologyPage> RenderLoadedTopology()
{
var cut = Render<TopologyPage>();
cut.InvokeAsync(async () =>
{
foreach (var site in await _siteRepo.GetAllSitesAsync())
{
await cut.Instance.LoadBranchAsync(site.Id, null);
foreach (var area in await _repo.GetAreasBySiteIdAsync(site.Id))
await cut.Instance.LoadBranchAsync(site.Id, area.Id);
}
cut.Render();
}).GetAwaiter().GetResult();
return cut;
}
/// <summary>Verifies that branch loads and refreshes serialize repository reads without dropping the user-selected page.</summary>
/// <param name="branchFirst">Whether the branch is loaded before the refresh starts.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task BranchAndRefresh_SerializeRepositoryReadsWithoutDroppingUserPage(bool branchFirst)
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
instances: [new Instance("First") { Id = 1, SiteId = 1 }]);
var cut = Render<TopologyPage>();
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<IReadOnlyList<Instance>>(TaskCreationOptions.RunContinuationsAsynchronously);
var calls = 0;
_repo.GetTopologyInstancesAsync(1, null, Arg.Any<int>(), 50, Arg.Any<CancellationToken>())
.Returns(call =>
{
if (Interlocked.Increment(ref calls) == 1)
{
entered.TrySetResult();
return release.Task;
}
return Task.FromResult<IReadOnlyList<Instance>>([new Instance("Next") { Id = 51, SiteId = 1 }]);
});
var first = cut.InvokeAsync(() => branchFirst
? cut.Instance.LoadBranchAsync(1, null, 50) : cut.Instance.LoadDeployedStateAsync());
await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));
Task second = Task.CompletedTask;
await cut.InvokeAsync(() =>
{
second = branchFirst ? cut.Instance.LoadDeployedStateAsync() : cut.Instance.LoadBranchAsync(1, null, 50);
});
try
{
Assert.False(second.IsCompleted);
Assert.Equal(1, Volatile.Read(ref calls));
}
finally
{
release.TrySetResult([new Instance("Next") { Id = 51, SiteId = 1 }]);
await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(2));
}
Assert.Equal(2, calls);
var field = typeof(TopologyPage).GetField("_allInstances", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
Assert.Equal(51, Assert.Single((List<Instance>)field.GetValue(cut.Instance)!).Id);
}
/// <summary>Verifies that branch pages are loaded lazily and each replaces the previous page.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BranchPages_AreLazyAndReplacePreviousPage()
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
instances: Enumerable.Range(1, 101).Select(id => new Instance($"Pump-{id}") { Id = id, SiteId = 1 }));
var cut = Render<TopologyPage>();
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null));
var field = typeof(TopologyPage).GetField("_allInstances", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
Assert.Equal(50, ((List<Instance>)field.GetValue(cut.Instance)!).Count);
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null, 50));
var second = (List<Instance>)field.GetValue(cut.Instance)!;
Assert.Equal(50, second.Count);
Assert.Equal(51, second[0].Id);
await _repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the initial load does not read instances or templates.</summary>
[Fact]
public void InitialLoad_DoesNotReadInstancesOrTemplates()
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
_repo.ClearReceivedCalls();
var cut = Render<TopologyPage>();
cut.WaitForAssertion(() => Assert.Contains("Plant", cut.Markup));
_repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
_repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>());
}
// ---- Deploy all (Gitea #40) ----
// One site (id 1) with one area (id 10) holding one Disabled instance — a bulk deploy
// skips Disabled instances before any flatten, so the real DeploymentService runs
// end to end against the substituted repositories.
private IRenderedComponent<DialogHost> SeedDeployAllTopology(int instanceCount = 1)
{
var site = new Site("Plant-A", "plant-a") { Id = 1 };
var area = new Area("Line-1") { Id = 10, SiteId = 1 };
var instances = Enumerable.Range(1, instanceCount)
.Select(i => new Instance($"Pump-{i:000}") { Id = 100 + i, SiteId = 1, AreaId = 10, State = InstanceState.Disabled })
.ToList();
SeedRepos(sites: [site], instances: instances, areasBySite: new() { [1] = new List<Area> { area } });
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(site);
_siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances));
_repo.GetAreaByIdAsync(10, Arg.Any<CancellationToken>()).Returns(area);
_pipeline.CreateSession().Returns(_ => new FlattenSession(new ZB.MOM.WW.ScadaBridge.Commons.Types.Templates.TemplateGraphWatermark()));
JSInterop.Mode = JSRuntimeMode.Loose;
return Render<DialogHost>();
}
private static void OpenMenu(IRenderedComponent<TopologyPage> cut, string label) =>
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == label).ContextMenu();
/// <summary>Deploy all appears on site and area context menus, not instance menus.</summary>
[Fact]
public void DeployAll_MenuItem_OnSiteAndAreaNodes_NotInstances()
{
SeedDeployAllTopology();
var cut = RenderLoadedTopology();
FindToggleForLabel(cut, "Line-1")!.Click();
OpenMenu(cut, "Pump-001");
Assert.DoesNotContain(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
OpenMenu(cut, "Plant-A");
Assert.Contains(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
OpenMenu(cut, "Line-1");
Assert.Contains(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
}
/// <summary>Confirming on an area calls the area deploy and toasts the summary, then reloads.</summary>
[Fact]
public void DeployAll_Area_Confirm_DeploysAndShowsSummaryToast()
{
var host = SeedDeployAllTopology();
var cut = RenderLoadedTopology();
OpenMenu(cut, "Line-1");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
var dialog = host.WaitForElement("[data-test='deploy-all-body']");
Assert.Contains("1 instance", host.Find("[data-test='deploy-all-counts']").TextContent);
Assert.Contains("1 disabled", host.Find("[data-test='deploy-all-counts']").TextContent);
host.Find("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() => Assert.Contains("0 deployed, 1 skipped, 0 failed", cut.Markup));
// preview + deploy each resolve the area.
_repo.Received(2).GetAreaByIdAsync(10, Arg.Any<CancellationToken>());
}
/// <summary>Confirming on a site calls the site deploy.</summary>
[Fact]
public void DeployAll_Site_Confirm_DeploysWholeSite()
{
var host = SeedDeployAllTopology(instanceCount: 2);
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
Assert.Contains("2 instances", host.WaitForElement("[data-test='deploy-all-counts']").TextContent);
host.Find("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() => Assert.True(cut.Markup.Contains("0 deployed, 2 skipped, 0 failed"), "toast missing"));
_siteRepo.Received(2).GetSiteByIdAsync(1, Arg.Any<CancellationToken>());
_repo.DidNotReceive().GetAreaByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>A failing instance yields an error toast listing the failure, not the success toast.</summary>
[Fact]
public void DeployAll_FailedInstance_ShowsErrorToastWithFailureList()
{
var host = SeedDeployAllTopology();
var failing = new Instance("Pump-001") { Id = 101, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed };
_siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { failing }));
_pipeline.FlattenAndValidateAsync(Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Failure("flatten boom")));
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
host.WaitForElement("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() =>
{
var toast = cut.Find(".toast-body").TextContent;
Assert.Contains("0 deployed, 0 skipped, 1 failed", toast);
Assert.Contains("Pump-001", toast);
});
}
/// <summary>A saved (collapsed) expansion state wins over InitiallyExpanded for sites.</summary>
[Fact]
public void InitialLoad_SavedCollapsedState_WinsOverInitiallyExpanded()
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
areasBySite: new() { [1] = new List<Area> { new("Line A") { Id = 10, SiteId = 1 } } });
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
var cut = Render<TopologyPage>();
cut.WaitForAssertion(() => Assert.Contains("Plant", cut.Markup));
var siteLi = cut.FindAll("li[role='treeitem']").First(li => li.QuerySelector(".tv-label")?.TextContent == "Plant");
Assert.Equal("false", siteLi.GetAttribute("aria-expanded"));
}
/// <summary>Cancel performs no deploy (only the preview read happened).</summary>
[Fact]
public void DeployAll_Cancel_DoesNothing()
{
var host = SeedDeployAllTopology();
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
host.WaitForElement("[data-test='deploy-all-cancel']").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("skipped", cut.Markup));
// Only the preview's single read of the site's instances; a deploy would add a second.
_siteRepo.Received(1).GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>());
Assert.Empty(host.FindAll("[data-test='deploy-all-body']"));
}
/// <summary>A subtree with no instances shows an info toast and never opens the dialog.</summary>
[Fact]
public void DeployAll_ZeroInstances_InfoToast_NoDialog()
{
var host = SeedDeployAllTopology(instanceCount: 0);
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
cut.WaitForAssertion(() => Assert.Contains("No instances under", cut.Markup));
host.Render();
Assert.Empty(host.FindAll("[data-test='deploy-all-body']"));
}
/// <summary>The dialog's Force checkbox is carried on the confirmed result.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DeployAllDialog_ConfirmCarriesForceChoice(bool force)
{
JSInterop.Mode = JSRuntimeMode.Loose;
var host = Render<DialogHost>();
var dialogs = Services.GetRequiredService<IDialogService>();
var pending = dialogs.ShowAsync<DeployAllDialog.DeployAllResult>("Deploy all", ctx => builder =>
{
builder.OpenComponent<DeployAllDialog>(0);
builder.AddAttribute(1, "Context", ctx);
builder.AddAttribute(2, "ScopeLabel", "site Plant-A");
builder.AddAttribute(3, "Total", 4);
builder.AddAttribute(4, "Disabled", 1);
builder.CloseComponent();
});
host.Render();
if (force) host.WaitForElement("[data-test='deploy-all-force']").Change(true);
host.WaitForElement("[data-test='deploy-all-confirm']").Click();
var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(result);
Assert.Equal(force, result!.Force);
}
/// <summary>Gitea #39: the first layer (areas of every permitted site) is loaded at open; instances stay lazy.</summary>
[Fact]
public void InitialLoad_PreloadsAreasForPermittedSites_ButNoInstances()
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }, new Site("Yard", "yard") { Id = 2 }],
areasBySite: new()
{
[1] = new List<Area> { new("Line A") { Id = 10, SiteId = 1 } },
[2] = new List<Area> { new("Dock") { Id = 20, SiteId = 2 } },
});
var cut = Render<TopologyPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Line A", cut.Markup);
Assert.Contains("Dock", cut.Markup);
});
_repo.DidNotReceive().GetTopologyInstancesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>Gitea #39: a site outside the user's scope never has its areas read.</summary>
[Fact]
public async Task InitialLoad_OutOfScopeSite_AreasNeverRead()
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }, new Site("Yard", "yard") { Id = 2 }],
areasBySite: new()
{
[1] = new List<Area> { new("Line A") { Id = 10, SiteId = 1 } },
[2] = new List<Area> { new("Dock") { Id = 20, SiteId = 2 } },
});
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "1"));
var cut = Render<TopologyPage>();
cut.WaitForAssertion(() => Assert.Contains("Line A", cut.Markup));
Assert.DoesNotContain("Dock", cut.Markup);
await _repo.DidNotReceive().GetAreasBySiteIdAsync(2, Arg.Any<CancellationToken>());
}
/// <summary>Gitea #39: with no saved expansion state sites open; a saved (collapsed) state wins.</summary>
[Fact]
public void InitialLoad_SitesExpanded_WhenNoSavedState_AndSavedStateWins()
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
areasBySite: new() { [1] = new List<Area> { new("Line A") { Id = 10, SiteId = 1 } } });
var cut = Render<TopologyPage>();
cut.WaitForAssertion(() => Assert.Contains("Line A", cut.Markup));
Assert.Contains("aria-expanded=\"true\"", cut.Markup);
}
/// <summary>Verifies that moving an existing topology item uses the current site scope.</summary>
/// <param name="revoked">Whether the user's site permission has been revoked.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UI13_ExistingTopologyMoveUsesCurrentSiteScope(bool revoked)
{
var instance = new Instance("Pump") { Id = 100, SiteId = 1, AreaId = 10 };
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }], instances: [instance]);
_repo.GetInstanceByIdAsync(100, Arg.Any<CancellationToken>()).Returns(instance);
var cut = RenderLoadedTopology();
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
var handler = typeof(TopologyPage).GetMethod("SubmitMoveInstance",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)handler.Invoke(cut.Instance, [(100, (int?)null)])!);
await _repo.Received(revoked ? 0 : 1).UpdateInstanceAsync(Arg.Any<Instance>(), Arg.Any<CancellationToken>());
Assert.Equal(revoked ? 10 : (int?)null, instance.AreaId);
}
/// <summary>Verifies that creating an item under an existing topology uses the current site scope.</summary>
/// <param name="revoked">Whether the user's site permission has been revoked.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UI13_ExistingTopologyCreateUsesCurrentSiteScope(bool revoked)
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
var cut = RenderLoadedTopology();
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
var handler = typeof(TopologyPage).GetMethod("SubmitCreateArea",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)handler.Invoke(cut.Instance, [(1, (int?)null, "New area")])!);
await _repo.Received(revoked ? 0 : 1).AddAreaAsync(Arg.Any<Area>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an empty state is rendered when there are no sites.</summary>
[Fact]
public void Renders_EmptyState_WhenNoSites()
{
SeedRepos();
var cut = RenderLoadedTopology();
Assert.Contains("No sites configured", cut.Markup);
}
/// <summary>Verifies that an empty site is rendered as a top-level node.</summary>
[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 = RenderLoadedTopology();
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");
/// <summary>Verifies that sites, areas and instances render in nested order.</summary>
[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 = RenderLoadedTopology();
// 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, "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);
}
/// <summary>Verifies that search dims non-matching nodes while preserving the tree shape.</summary>
[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 = RenderLoadedTopology();
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"));
}
/// <summary>Verifies that a deployment user scoped to specific sites sees only the permitted sites.</summary>
[Fact]
public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites()
{
// Regression test . 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(JwtTokenService.UsernameClaimType, "scoped-tester"),
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployer"),
// 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 = RenderLoadedTopology();
// The permitted site is rendered; the non-permitted site is not.
Assert.Contains("Plant-A", cut.Markup);
Assert.DoesNotContain("Plant-B", cut.Markup);
}
/// <summary>Verifies that a system-wide deployment user sees all sites.</summary>
[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 = RenderLoadedTopology();
Assert.Contains("Plant-A", cut.Markup);
Assert.Contains("Plant-B", cut.Markup);
}
/// <summary>Verifies that double-clicking an area label enters rename mode.</summary>
[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 = RenderLoadedTopology();
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"));
}
/// <summary>
/// Gitea #38: typing a new name and pressing Enter must persist the rename. The buffer
/// used to bind on <c>onchange</c>, which a browser fires only after the Enter keydown
/// (or on blur, which cancels) — so the commit always saw the original name and no-oped.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InlineRename_TypeThenEnter_PersistsNewName()
{
var area = new Area("Line-1") { Id = 10, SiteId = 1 };
var areasBySite = new Dictionary<int, IReadOnlyList<Area>> { [1] = new List<Area> { area } };
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
_repo.GetAreaByIdAsync(10, Arg.Any<CancellationToken>()).Returns(area);
var cut = RenderLoadedTopology();
cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1").DoubleClick();
var input = cut.Find("input[aria-label='Rename Line-1']");
// Keystroke-level binding: no change event (that only arrives after Enter, or on blur).
input.Input("Line-1-renamed");
await cut.Find("input[aria-label='Rename Line-1']").KeyDownAsync(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" });
await _repo.Received(1).UpdateAreaAsync(
Arg.Is<Area>(a => a.Id == 10 && a.Name == "Line-1-renamed"), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that instance rows do not support double-click rename.</summary>
[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 = RenderLoadedTopology();
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick());
}
/// <summary>Verifies that a connection endpoint change renders a connection section in the diff.</summary>
[Fact]
public void Diff_ConnectionEndpointChange_RendersConnectionSection()
{
// A standalone connection
// endpoint edit (no per-attribute binding change) must surface in the
// deployment-diff modal. Before ConnectionChanges was wired through
// ComputeDiff + the UI, this redeploy showed only the stale-hash badge
// with no indication that the connection endpoint had moved.
// The DiffDialog body-scroll lock + focus call out to JS interop on
// open; loose mode no-ops the handlers we don't explicitly set up.
JSInterop.Mode = JSRuntimeMode.Loose;
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 } },
instances: new[]
{
new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.Enabled }
},
areasBySite: areasBySite);
// Deployed snapshot: connection "plc1" points at host-a.
var deployedConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-001",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
FailoverRetryCount = 3,
}
}
};
_deployRepo.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<DeployedConfigSnapshot?>(
new DeployedConfigSnapshot("dep-1", "hash-old",
JsonSerializer.Serialize(deployedConfig))));
// Current template-derived config: same connection now points at host-b.
var currentConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-001",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}",
FailoverRetryCount = 3,
}
}
};
_pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
var cut = RenderLoadedTopology();
FindToggleForLabel(cut, "Line-1")!.Click();
// The per-node action menu only renders after a context-menu (right
// click) on the instance row, so open it first, then click "Diff".
var instanceRow = cut.FindAll(".tv-row")
.First(row => row.QuerySelector(".tv-label")?.TextContent == "Pump-001");
instanceRow.ContextMenu();
var diffButton = cut.FindAll("button.dropdown-item")
.First(b => b.TextContent.Trim() == "Diff");
diffButton.Click();
var markup = cut.Markup;
Assert.Contains("Connections", markup);
Assert.Contains("plc1", markup);
Assert.Contains("host-a", markup);
Assert.Contains("host-b", markup);
// The change is a modification, so the row carries the "Changed" badge.
Assert.Contains("Changed", markup);
}
/// <summary>
/// Seeds one site with one deployed (Enabled) instance whose deployment
/// comparison resolves: a deployed snapshot plus a current flatten. Used by
/// the staleness-lifecycle tests below, which assert on how OFTEN the
/// comparison runs (via the snapshot repository call count), not on the
/// resulting Stale/Current value.
/// </summary>
private void SeedOneDeployedInstanceWithComparison()
{
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
instances: new[]
{
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.Enabled }
});
var deployedConfig = new FlattenedConfiguration { InstanceUniqueName = "Pump-001" };
_deployRepo.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<DeployedConfigSnapshot?>(
new DeployedConfigSnapshot("dep-1", "hash-old",
JsonSerializer.Serialize(deployedConfig))));
var currentConfig = new FlattenedConfiguration { InstanceUniqueName = "Pump-001" };
_pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
}
/// <summary>Verifies that a live update does not recompute staleness.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LiveUpdate_DoesNotRecomputeStaleness()
{
// Performance regression guard: staleness (the expensive per-instance
// re-flatten via DeploymentService.GetDeploymentComparisonAsync) must NOT
// run on the 15s live-update poll. The live-update path refreshes only the
// cheap deployed state (LoadDeployedStateAsync), which the timer invokes
// directly. The comparison reaches into the snapshot repository, so its
// call count is the proxy for "did staleness recompute".
SeedOneDeployedInstanceWithComparison();
var cut = RenderLoadedTopology();
// Initial load computed staleness exactly once.
await _deployRepo.Received(1)
.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
// Drive the EXACT code path the live-update timer runs.
await cut.InvokeAsync(() => cut.Instance.LoadDeployedStateAsync());
// Staleness was NOT recomputed by the live-update refresh — still once.
await _deployRepo.Received(1)
.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the deployed state renders after the split.</summary>
[Fact]
public void DeployedState_Renders_AfterSplit()
{
// After splitting the load into fast-state vs expensive-staleness, the
// cheap deployed state (hierarchy + State badge) must still render.
SeedOneDeployedInstanceWithComparison();
var cut = RenderLoadedTopology();
Assert.Contains("Pump-001", cut.Markup);
Assert.Contains("Enabled", cut.Markup);
// The deployed instance shows a staleness badge (Stale or Current).
Assert.Matches("Stale|Current", cut.Markup);
}
/// <summary>Verifies that a manual re-check recomputes staleness.</summary>
[Fact]
public void ManualRecheck_RecomputesStaleness()
{
// The "Re-check staleness" button lets operators refresh staleness on
// demand (since it's off the live loop). Clicking it runs the comparison
// again — a second snapshot-repository call.
SeedOneDeployedInstanceWithComparison();
var cut = RenderLoadedTopology();
_deployRepo.Received(1)
.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
cut.Find("button[aria-label='Re-check staleness']").Click();
_deployRepo.Received(2)
.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the legacy instances route is declared on the Topology page.</summary>
[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);
}
}