feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user