Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs
T
Joseph Doherty dd4f1c2aa3 fix(alarms): Severity runs one direction, higher = more urgent; raw priority kept (#129)
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9
Severity (higher = more severe) while MxGateway native alarms and computed
alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer
ranks higher as worse, so Worst Severity, the severity filter/sort and the
site event-log Error threshold picked the LEAST urgent MxGateway/computed
alarms.

- AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887,
  b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500,
  751-999 -> 250; <= 0 clamps to Critical, > 999 to Low.
- MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority);
  AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged.
- AlarmConditionState gains trailing Priority (int?): the raw source number,
  exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire
  AlarmStateUpdate.priority keep that meaning (no proto change); central derives
  Severity by kind (SeverityFromPriority), so an older site decodes correctly.
- ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES
  API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved
  from a.Severity to a.Priority, so MES output is unchanged.
- AlarmActor raise logs Error when the banded severity >= 700 (same threshold
  as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700).
- NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate.
- Debug snapshot decode restores Condition.Priority from the row's priority.
- Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI,
  HighLevelReqs, MES alarm-status plan, CLAUDE.md.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 14:02:54 -05:00

245 lines
12 KiB
C#

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"/> (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);
/// <summary>Verifies that site alarms are aggregated from reporting instances and the failed instance is recorded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>());
}
/// <summary>Verifies that an instance-not-found snapshot goes to the not-reporting list.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that the rollup computes the worst severity, the active and unacknowledged counts and the kind counts.</summary>
[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 priority 999 = AVEVA Low → Severity 250 (#129) — 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 = 800 (TankHi); the computed priority 999 is the LEAST urgent
Assert.Equal(800, 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]);
}
/// <summary>
/// Gitea #129: "Worst Severity" is the most URGENT alarm across every kind. AVEVA priority 1 (computed or
/// MxAccess) bands to 1000 and outranks priority 999 (250) and an OPC UA Severity of 900.
/// </summary>
[Fact]
public void ComputeRollup_WorstSeverity_IsTheMostUrgentPriority()
{
var mx = new AlarmStateChanged("inst-a", "MtrTrip", AlarmState.Active, 1, Now)
{
Kind = AlarmKind.NativeMxAccess,
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false,
AlarmPriorityBands.ToSeverity(1), Priority: 1),
};
var rows = new List<AlarmSummaryRow>
{
new("inst-a", ComputedAlarm("inst-a", "Minor", AlarmState.Active, 999)),
new("inst-b", NativeAlarm("inst-b", "TankHi", AlarmState.Active, 900, active: true, acked: false)),
new("inst-a", mx),
};
Assert.Equal(1000, CreateSut().ComputeRollup(rows).WorstSeverity);
var computedOnly = new List<AlarmSummaryRow>
{
new("inst-a", ComputedAlarm("inst-a", "Minor", AlarmState.Active, 999)),
new("inst-a", ComputedAlarm("inst-a", "Major", AlarmState.Active, 1)),
};
Assert.Equal(1000, CreateSut().ComputeRollup(computedOnly).WorstSeverity);
}
/// <summary>Verifies that a rollup with no active rows has a worst severity of zero.</summary>
[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);
}
// ── BuildFromLiveAlarms ────────────────────────────────
/// <summary>Verifies that building from live alarms flattens and sorts the same way as the poll path, with an empty not-reporting list.</summary>
[Fact]
public void BuildFromLiveAlarms_FlattensAndSorts_SameAsPollPath_WithEmptyNotReporting()
{
// Intentionally out-of-order input to prove the deterministic
// instance-then-alarm-name sort matches GetSiteAlarmsAsync.
var alarms = new List<AlarmStateChanged>
{
NativeAlarm("zeta", "Zulu", AlarmState.Active, 900, active: true, acked: false),
NativeAlarm("alpha", "Bravo", AlarmState.Active, 500, active: true, acked: true),
NativeAlarm("alpha", "Alpha", AlarmState.Active, 700, active: true, acked: false),
};
var result = CreateSut().BuildFromLiveAlarms(alarms);
// One row per alarm, instance taken from the alarm itself.
Assert.Equal(3, result.Alarms.Count);
Assert.Collection(result.Alarms,
r => Assert.Equal(("alpha", "Alpha"), (r.InstanceUniqueName, r.Alarm.AlarmName)),
r => Assert.Equal(("alpha", "Bravo"), (r.InstanceUniqueName, r.Alarm.AlarmName)),
r => Assert.Equal(("zeta", "Zulu"), (r.InstanceUniqueName, r.Alarm.AlarmName)));
// The live path can't compute "not reporting" — always empty.
Assert.Empty(result.NotReportingInstances);
}
/// <summary>Verifies that building from an empty live snapshot yields no rows.</summary>
[Fact]
public void BuildFromLiveAlarms_EmptySnapshot_YieldsNoRows()
{
var result = CreateSut().BuildFromLiveAlarms(Array.Empty<AlarmStateChanged>());
Assert.Empty(result.Alarms);
Assert.Empty(result.NotReportingInstances);
}
}