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; /// /// Unit tests for (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. /// 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(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly IInstanceSnapshotClient _snapshotClient = Substitute.For(); 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 }; /// Builds a native-style alarm with explicit condition flags. 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(), alarms, Now); [Fact] public async Task GetSiteAlarmsAsync_AggregatesReportingInstances_AndRecordsTheFailedOne() { _siteRepo.GetSiteByIdAsync(SiteId, Arg.Any()).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()) .Returns(new List { 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()) .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()) .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()) .Returns(_ => 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()); await _snapshotClient.DidNotReceive() .GetSnapshotAsync(SiteIdentifier, "inst-e", Arg.Any()); } [Fact] public async Task GetSiteAlarmsAsync_InstanceNotFoundSnapshot_GoesToNotReporting() { _siteRepo.GetSiteByIdAsync(SiteId, Arg.Any()).Returns(Site()); _instanceRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any()) .Returns(new List { Instance(1, "ghost", InstanceState.Enabled) }); _snapshotClient.GetSnapshotAsync(SiteIdentifier, "ghost", Arg.Any()) .Returns(new DebugViewSnapshot( "ghost", Array.Empty(), Array.Empty(), 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 { // 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 { 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); } }