feat(central-ui): Sites page shows whether a site's deployed artifacts are current
Each site card carries an Artifacts badge (current / out of date / never deployed) plus an "N of M instances out of date" line linking to Topology, both read from the shared pending-updates snapshot. The snapshot loads after the site list so a slow fleet evaluation never holds the cards, and a failure degrades the badge to "unavailable" instead of taking the page down. Header gains a Re-check button and an as-of caption; a successful Deploy Artifacts forces a refresh so the badge flips immediately. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -6,11 +6,13 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
|
||||
using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
|
||||
@@ -25,11 +27,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
|
||||
public class SitesPageTests : BunitContext
|
||||
{
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IPendingUpdatesCache _pendingUpdates = Substitute.For<IPendingUpdatesCache>();
|
||||
|
||||
private void RegisterServices()
|
||||
{
|
||||
Services.AddSingleton(_siteRepo);
|
||||
|
||||
// Default: an empty snapshot, so the page's badges resolve without a stub.
|
||||
// Individual tests re-stub GetAsync before rendering.
|
||||
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult(Snapshot()));
|
||||
Services.AddSingleton(_pendingUpdates);
|
||||
|
||||
var comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
@@ -71,6 +80,27 @@ public class SitesPageTests : BunitContext
|
||||
private static DataConnection Conn(int siteId, string name)
|
||||
=> new(name, "OpcUa", siteId);
|
||||
|
||||
private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites)
|
||||
=> new(new DateTimeOffset(2026, 9, 14, 10, 30, 0, TimeSpan.Zero), sites);
|
||||
|
||||
private static SitePendingUpdates Pending(
|
||||
int siteId,
|
||||
ArtifactDeploymentState state,
|
||||
int deployed = 0,
|
||||
int stale = 0,
|
||||
int unknown = 0)
|
||||
=> new(
|
||||
siteId,
|
||||
$"SITE-{siteId}",
|
||||
$"Site{siteId}",
|
||||
state,
|
||||
state == ArtifactDeploymentState.NeverDeployed
|
||||
? null
|
||||
: new DateTimeOffset(2026, 9, 13, 8, 0, 0, TimeSpan.Zero),
|
||||
deployed,
|
||||
stale,
|
||||
unknown);
|
||||
|
||||
[Fact]
|
||||
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
|
||||
{
|
||||
@@ -103,4 +133,83 @@ public class SitesPageTests : BunitContext
|
||||
Assert.Contains("alpha-conn", cut.Markup);
|
||||
Assert.Contains("beta-conn", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed()
|
||||
{
|
||||
RegisterServices();
|
||||
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
|
||||
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
|
||||
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult(Snapshot(
|
||||
Pending(1, ArtifactDeploymentState.Current),
|
||||
Pending(2, ArtifactDeploymentState.OutOfDate),
|
||||
Pending(3, ArtifactDeploymentState.NeverDeployed))));
|
||||
|
||||
var cut = Render<SitesPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("aria-label=\"Artifacts: current\"", cut.Markup);
|
||||
Assert.Contains("Artifacts current", cut.Markup);
|
||||
Assert.Contains("aria-label=\"Artifacts: out of date\"", cut.Markup);
|
||||
Assert.Contains("Artifacts out of date", cut.Markup);
|
||||
Assert.Contains("aria-label=\"Artifacts: never deployed\"", cut.Markup);
|
||||
Assert.Contains("Artifacts never deployed", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceLine_ShowsStaleOfDeployed()
|
||||
{
|
||||
RegisterServices();
|
||||
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
|
||||
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
|
||||
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => Task.FromResult(Snapshot(
|
||||
Pending(1, ArtifactDeploymentState.Current, deployed: 5, stale: 2, unknown: 1))));
|
||||
|
||||
var cut = Render<SitesPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("2 of 5 instances out of date", cut.Markup);
|
||||
Assert.Contains("1 could not be evaluated", cut.Markup);
|
||||
Assert.Contains("/deployment/topology", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recheck_ForcesRefresh()
|
||||
{
|
||||
RegisterServices();
|
||||
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
|
||||
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
|
||||
|
||||
var cut = Render<SitesPage>();
|
||||
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
_pendingUpdates.Received().GetAsync(true, Arg.Any<CancellationToken>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheFault_ShowsUnavailable_AndSitesStillRender()
|
||||
{
|
||||
RegisterServices();
|
||||
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
|
||||
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
|
||||
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
|
||||
|
||||
var cut = Render<SitesPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The snapshot failing must never take the site list down with it.
|
||||
Assert.Contains("Site1", cut.Markup);
|
||||
Assert.Contains("Site2", cut.Markup);
|
||||
Assert.Contains("Artifacts: unavailable", cut.Markup);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user