merge(deferred-10): aggregated live alarm stream for Alarm Summary

Merges plan #10 (feat/live-alarm-stream) into main alongside plan #22.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 13:01:04 -04:00
36 changed files with 3327 additions and 53 deletions
@@ -167,4 +167,40 @@ public class AlarmSummaryServiceTests
Assert.Equal(0, rollup.WorstSeverity);
Assert.Equal(0, rollup.UnackedCount);
}
// ── BuildFromLiveAlarms (plan #10, Task 5) ────────────────────────────────
[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);
}
[Fact]
public void BuildFromLiveAlarms_EmptySnapshot_YieldsNoRows()
{
var result = CreateSut().BuildFromLiveAlarms(Array.Empty<AlarmStateChanged>());
Assert.Empty(result.Alarms);
Assert.Empty(result.NotReportingInstances);
}
}