feat(alarm-summary): live-cache-driven updates with poll fallback (plan #10 T5)

Wire the operator Alarm Summary page to the transient per-site live alarm
cache (ISiteAlarmLiveCache, T4). Live-cache-first: on site select the page
subscribes and rebuilds rows/rollup from near-real-time onChanged deltas; the
15s poll is kept untouched as the authority for NotReporting and as the
safety net whenever the cache is not live (pre-seed or degraded stream). Both
paths mutate shared state only via the Blazor dispatcher, so they never race,
and each rebuild is an idempotent snapshot.

- IAlarmSummaryService.BuildFromLiveAlarms: flattens a live AlarmStateChanged
  snapshot to AlarmSummaryRows with the same deterministic instance-then-name
  sort as GetSiteAlarmsAsync; NotReporting always empty on the live path.
- AlarmSummary.razor: inject ISiteAlarmLiveCache; subscribe on select,
  re-subscribe on site change, unsubscribe on leave + Dispose.
- Tests: service BuildFromLiveAlarms flatten/sort/empty; page subscribe,
  poll-fallback render, onChanged rebuild, unsubscribe on leave/change/dispose.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 12:25:36 -04:00
parent 696a4ffea2
commit b91ed3c840
5 changed files with 304 additions and 2 deletions
@@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary;
@@ -26,6 +27,7 @@ public class AlarmSummaryRenderTests : BunitContext
{
private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly FakeSiteAlarmLiveCache _liveCache = new();
public AlarmSummaryRenderTests()
{
@@ -39,14 +41,33 @@ public class AlarmSummaryRenderTests : BunitContext
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
// BuildFromLiveAlarms (Task 5): flatten the pushed live snapshot the same way
// the real service does, so the live-path render tests exercise real behavior.
_summary.BuildFromLiveAlarms(Arg.Any<IReadOnlyList<AlarmStateChanged>>())
.Returns(ci =>
{
var alarms = ci.Arg<IReadOnlyList<AlarmStateChanged>>();
var built = alarms
.Select(a => new AlarmSummaryRow(a.InstanceUniqueName, a))
.OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AlarmSummaryResult(built, Array.Empty<string>());
});
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
.Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
Services.AddSingleton(_summary);
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { new("Site 1", "site1") { Id = 1 } }));
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Site 1", "site1") { Id = 1 },
new("Site 2", "site2") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
Services.AddSingleton<ISiteAlarmLiveCache>(_liveCache);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
@@ -109,4 +130,133 @@ public class AlarmSummaryRenderTests : BunitContext
private static string FirstRowInstance(IRenderedComponent<AlarmSummaryPage> cut) =>
cut.FindAll("tr[data-test='alarm-summary-row']")[0]
.QuerySelector("td")!.TextContent.Trim();
// ── plan #10 Task 5: live-cache-first with poll fallback ───────────────────
[Fact]
public void SelectingSite_SubscribesToTheLiveCache()
{
RenderWithSiteSelected();
Assert.Equal(1, _liveCache.SubscribeCount);
Assert.Equal(1, _liveCache.LastSubscribedSite);
}
[Fact]
public void PollFallbackRenders_WhenCacheNotLiveYet()
{
// Cache starts not-live, so the site select falls back to the poll's
// GetSiteAlarmsAsync snapshot (Zeta + Alpha).
Assert.False(_liveCache.IsLive(1));
var cut = RenderWithSiteSelected();
Assert.Equal(2, cut.FindAll("tr[data-test='alarm-summary-row']").Count);
Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default
}
[Fact]
public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot()
{
var cut = RenderWithSiteSelected();
// Push a fresh live snapshot: a single new alarm on a new instance.
_liveCache.PushAlarms(new List<AlarmStateChanged>
{
new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
});
cut.WaitForAssertion(() =>
{
var rows = cut.FindAll("tr[data-test='alarm-summary-row']");
Assert.Single(rows);
Assert.Equal("Gamma", FirstRowInstance(cut));
});
}
[Fact]
public void LeavingSite_UnsubscribesFromTheLiveCache()
{
var cut = RenderWithSiteSelected();
Assert.Equal(0, _liveCache.DisposeCount);
// Clearing the site picker tears the subscription down.
cut.Find("[data-test='alarm-summary-site']").Change("");
Assert.Equal(1, _liveCache.DisposeCount);
}
[Fact]
public void ChangingSite_ResubscribesToTheNewSite()
{
var cut = RenderWithSiteSelected(); // site 1
cut.Find("[data-test='alarm-summary-site']").Change("2");
Assert.Equal(2, _liveCache.SubscribeCount); // one per site
Assert.Equal(1, _liveCache.DisposeCount); // old subscription disposed
Assert.Equal(2, _liveCache.LastSubscribedSite);
}
[Fact]
public void DisposingComponent_UnsubscribesFromTheLiveCache()
{
var cut = RenderWithSiteSelected();
cut.Instance.Dispose();
Assert.True(_liveCache.DisposeCount >= 1);
}
/// <summary>
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
/// <see cref="IsLive"/> true and fires the stored onChanged), so the page's
/// live-delta path can be driven deterministically.
/// </summary>
private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache
{
private Action? _onChanged;
private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>();
private bool _live;
public int SubscribeCount { get; private set; }
public int DisposeCount { get; private set; }
public int? LastSubscribedSite { get; private set; }
public IDisposable Subscribe(int siteId, Action onChanged)
{
SubscribeCount++;
LastSubscribedSite = siteId;
_onChanged = onChanged;
return new Subscription(this);
}
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current;
public bool IsLive(int siteId) => _live;
public void PushAlarms(IReadOnlyList<AlarmStateChanged> alarms)
{
_current = alarms;
_live = true;
_onChanged?.Invoke();
}
private sealed class Subscription : IDisposable
{
private readonly FakeSiteAlarmLiveCache _owner;
private bool _disposed;
public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_owner.DisposeCount++;
_owner._onChanged = null;
}
}
}
}
@@ -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);
}
}