feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13)

This commit is contained in:
Joseph Doherty
2026-06-18 02:21:41 -04:00
parent 6a6f8949b9
commit 3c9122bc07
8 changed files with 872 additions and 0 deletions
@@ -0,0 +1,170 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Unit tests for <see cref="AlarmSummaryService"/> (M7 T13 — Operator Alarm
/// Summary). The snapshot client and instance repository are substituted so the
/// fan-out + best-effort degradation + roll-up math are exercised without any
/// cluster traffic.
/// </summary>
public class AlarmSummaryServiceTests
{
private const int SiteId = 7;
private const string SiteIdentifier = "plant-a";
private static readonly DateTimeOffset Now =
new(2026, 6, 18, 12, 0, 0, TimeSpan.Zero);
private readonly ITemplateEngineRepository _instanceRepo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IInstanceSnapshotClient _snapshotClient = Substitute.For<IInstanceSnapshotClient>();
private AlarmSummaryService CreateSut() =>
new(_instanceRepo, _siteRepo, _snapshotClient);
private static Instance Instance(int id, string uniqueName, InstanceState state) =>
new(uniqueName) { Id = id, SiteId = SiteId, State = state };
private static Site Site() =>
new("Plant A", SiteIdentifier) { Id = SiteId };
/// <summary>Builds a native-style alarm with explicit condition flags.</summary>
private static AlarmStateChanged NativeAlarm(
string instance, string name, AlarmState state, int severity,
bool active, bool acked, AlarmKind kind = AlarmKind.NativeOpcUa) =>
new(instance, name, state, severity, Now)
{
Kind = kind,
Condition = new AlarmConditionState(
Active: active, Acknowledged: acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: severity),
};
private static AlarmStateChanged ComputedAlarm(
string instance, string name, AlarmState state, int priority) =>
new(instance, name, state, priority, Now) { Kind = AlarmKind.Computed };
private static DebugViewSnapshot Snapshot(string instance, params AlarmStateChanged[] alarms) =>
new(instance, Array.Empty<AttributeValueChanged>(), alarms, Now);
[Fact]
public async Task GetSiteAlarmsAsync_AggregatesReportingInstances_AndRecordsTheFailedOne()
{
_siteRepo.GetSiteByIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns(Site());
var enabledA = Instance(1, "inst-a", InstanceState.Enabled);
var enabledB = Instance(2, "inst-b", InstanceState.Enabled);
var enabledC = Instance(3, "inst-c", InstanceState.Enabled);
var disabled = Instance(4, "inst-d", InstanceState.Disabled);
var notDeployed = Instance(5, "inst-e", InstanceState.NotDeployed);
_instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { enabledA, enabledB, enabledC, disabled, notDeployed });
// inst-a: 2 alarms (one active native sev 800 unacked, one computed normal)
_snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-a", Arg.Any<CancellationToken>())
.Returns(Snapshot("inst-a",
NativeAlarm("inst-a", "TankHi", AlarmState.Active, 800, active: true, acked: false),
ComputedAlarm("inst-a", "PumpFault", AlarmState.Normal, 200)));
// inst-b: 1 alarm (active native sev 500 acked)
_snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-b", Arg.Any<CancellationToken>())
.Returns(Snapshot("inst-b",
NativeAlarm("inst-b", "ValveStuck", AlarmState.Active, 500, active: true, acked: true)));
// inst-c: snapshot fetch throws → not-reporting
_snapshotClient.GetSnapshotAsync(SiteIdentifier, "inst-c", Arg.Any<CancellationToken>())
.Returns<DebugViewSnapshot>(_ => throw new TimeoutException("site silent"));
var sut = CreateSut();
var result = await sut.GetSiteAlarmsAsync(SiteId);
// Aggregated alarm count = sum of the two reporting instances (2 + 1).
Assert.Equal(3, result.Alarms.Count);
Assert.Equal(2, result.Alarms.Count(r => r.InstanceUniqueName == "inst-a"));
Assert.Single(result.Alarms, r => r.InstanceUniqueName == "inst-b");
// The throwing instance is recorded, and only it.
Assert.Equal(new[] { "inst-c" }, result.NotReportingInstances);
// Disabled / not-deployed instances are never fetched.
await _snapshotClient.DidNotReceive()
.GetSnapshotAsync(SiteIdentifier, "inst-d", Arg.Any<CancellationToken>());
await _snapshotClient.DidNotReceive()
.GetSnapshotAsync(SiteIdentifier, "inst-e", Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetSiteAlarmsAsync_InstanceNotFoundSnapshot_GoesToNotReporting()
{
_siteRepo.GetSiteByIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns(Site());
_instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
.Returns(new List<Instance> { Instance(1, "ghost", InstanceState.Enabled) });
_snapshotClient.GetSnapshotAsync(SiteIdentifier, "ghost", Arg.Any<CancellationToken>())
.Returns(new DebugViewSnapshot(
"ghost", Array.Empty<AttributeValueChanged>(),
Array.Empty<AlarmStateChanged>(), Now, InstanceNotFound: true));
var result = await CreateSut().GetSiteAlarmsAsync(SiteId);
Assert.Empty(result.Alarms);
Assert.Equal(new[] { "ghost" }, result.NotReportingInstances);
}
[Fact]
public void ComputeRollup_ComputesWorstSeverityActiveAndUnackedAndKindCounts()
{
var rows = new List<AlarmSummaryRow>
{
// active native, sev 800, unacked → counts toward unacked
new("inst-a", NativeAlarm("inst-a", "TankHi", AlarmState.Active, 800, active: true, acked: false)),
// active native, sev 500, acked → not unacked
new("inst-b", NativeAlarm("inst-b", "ValveStuck", AlarmState.Active, 500, active: true, acked: true)),
// active mxaccess, sev 300, unacked → unacked
new("inst-b", NativeAlarm("inst-b", "MtrTrip", AlarmState.Active, 300, active: true, acked: false, kind: AlarmKind.NativeMxAccess)),
// computed normal (not active) → ignored by active/unacked, severity 200 not counted
new("inst-a", ComputedAlarm("inst-a", "PumpFault", AlarmState.Normal, 200)),
// computed active sev 999 — active, but computed so NOT unacked
new("inst-c", ComputedAlarm("inst-c", "HiHi", AlarmState.Active, 999)),
};
var rollup = CreateSut().ComputeRollup(rows);
// Active = the 3 native actives + the active computed = 4
Assert.Equal(4, rollup.TotalActive);
// Worst severity among active = 999 (the active computed)
Assert.Equal(999, rollup.WorstSeverity);
// Unacked = active && !acked && kind != Computed = TankHi + MtrTrip = 2
Assert.Equal(2, rollup.UnackedCount);
// Kind counts: NativeOpcUa 2, NativeMxAccess 1, Computed 2
Assert.Equal(2, rollup.CountsByKind[AlarmKind.NativeOpcUa]);
Assert.Equal(1, rollup.CountsByKind[AlarmKind.NativeMxAccess]);
Assert.Equal(2, rollup.CountsByKind[AlarmKind.Computed]);
}
[Fact]
public void ComputeRollup_NoActiveRows_WorstSeverityZero()
{
var rows = new List<AlarmSummaryRow>
{
new("inst-a", NativeAlarm("inst-a", "Cleared", AlarmState.Normal, 700, active: false, acked: true)),
};
var rollup = CreateSut().ComputeRollup(rows);
Assert.Equal(0, rollup.TotalActive);
Assert.Equal(0, rollup.WorstSeverity);
Assert.Equal(0, rollup.UnackedCount);
}
}