637 lines
28 KiB
C#
637 lines
28 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
|
|
{
|
|
[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>();
|
|
|
|
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 (Task 6): 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>();
|
|
// 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));
|
|
// P7-6: 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 (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).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;
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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>());
|
|
}
|
|
|
|
[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>());
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_EmptyState_WhenNoSites()
|
|
{
|
|
SeedRepos();
|
|
|
|
var cut = RenderLoadedTopology();
|
|
|
|
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 = 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");
|
|
|
|
[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, "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 = RenderLoadedTopology();
|
|
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(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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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();
|
|
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 = RenderLoadedTopology();
|
|
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 Diff_ConnectionEndpointChange_RendersConnectionSection()
|
|
{
|
|
// TemplateEngine-018 / DeploymentManager-018: 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, "Plant-A")!.Click();
|
|
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()))));
|
|
}
|
|
|
|
[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>());
|
|
}
|
|
|
|
[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();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
|
|
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);
|
|
}
|
|
|
|
[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>());
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|