b34ec10f8b
P7-6 (behaviour fix, plan decision 1): DeploySiteAsync redeployed EVERY deployable instance unconditionally. A redeploy is not idempotent at the site — it resets static attribute overrides and clears native alarm state by design — so one site-wide push destroyed live operational state on every unchanged instance in the batch, which is normally most of them. Bulk deploy now pre-filters with IStaleInstanceProbe (the same seam the bundle importer and Deployments page use; its memo timing was fixed by the Medium wave's P7-4): an instance whose freshly computed revision hash still matches its DeployedConfigSnapshot is reported in the existing additive SkippedInstances list with reason "up-to-date" and never flattened, staged or sent. The filter is deliberately conservative — every uncertainty deploys: only an Enabled instance qualifies, a missing snapshot disqualifies, a prior record that ShouldQuerySiteBeforeRedeploy flags goes through prepare so the reconcile that HEALS it still runs, and a null/faulting probe means "indeterminate". New additive MgmtDeploySiteCommand.Force (default false = the new filtered behaviour) plus CLI `deploy site --force` restores the unconditional push for the case central cannot see (restored volume, rebuilt node). Composes with the Medium wave's P7-2: Disabled wins — force suppresses the up-to-date filter only, never the Disabled skip. P7-5 (verified, then fixed): the cascade REPRODUCES. All three bulk phases share one scoped DbContext, and a failed SaveChanges does not untrack the offending entity — EF re-attempts it on the next save, including MarkDeploymentFailedAsync's cleanup write. Measured on a real SQLite-backed ScadaBridgeDbContext: a permanent save failure on instance 1's post-success side effects failed instances 2, 3 and 4 and left all three deployment records stranded InProgress. MarkDeploymentFailedAsync now detaches the pending changes (new IDeploymentManagerRepository.DetachPendingChanges) when the cleanup save fails and retries the Failed write once on the clean tracker, so later instances save cleanly and the record reaches a terminal status. The DeployFailed audit row moved outside the status-write try (best-effort, must not be lost because the status write failed). Tests: 5 new DeploySiteAsyncTests (skip + reason + no site round-trip; --force deploys; Disabled skipped under both force values; the three not-enough-to-skip guards), 2 new IntegrationTests over a real EF context (the poisoned-context cascade, and the narrow EF tracking fact the fix rests on), CLI --force flag parse test, and a MgmtDeploySite wire-default test. Proof of failure: both P7-6 behaviour tests and both P7-5 assertions fail against the unfixed code.
498 lines
21 KiB
C#
498 lines
21 KiB
C#
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
|
|
{
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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(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 = 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 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 = Render<TopologyPage>();
|
|
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 = Render<TopologyPage>();
|
|
|
|
// 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 = Render<TopologyPage>();
|
|
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 = Render<TopologyPage>();
|
|
_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);
|
|
}
|
|
}
|