feat(sitestream): add central SubscribeSiteAsync alarm-only client (plan #10 T3)

Add SiteStreamGrpcClient.SubscribeSiteAsync mirroring SubscribeAsync but for
the site-wide, alarm-only SubscribeSite RPC: builds a SiteStreamRequest, opens
the server stream, and delivers each event via a typed Action<AlarmStateChanged>
callback (this stream is alarm-only by contract, so Task 4's per-site cache
consumes an alarm delta with no downstream type test). Reuses the shared
enrichment mapping via a new internal ConvertToAlarmEvent helper that returns
null for any non-alarm event, defensively filtering anything that should never
appear on the stream. Factory unchanged - it already caches a client per
(site, endpoint). Adds focused unit tests for the alarm-only filter and the
test-only-client guard.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 11:49:49 -04:00
parent 9c631b47e1
commit 6d5ecc92a5
2 changed files with 151 additions and 0 deletions
@@ -208,6 +208,78 @@ public class SiteStreamGrpcClientTests
Assert.True(cts2.IsCancellationRequested);
}
// --- Site-wide (SubscribeSite) alarm-only stream (plan #10 T3) ---
[Fact]
public void ConvertToAlarmEvent_AlarmChanged_ReturnsMappedAlarm()
{
// An alarm event on the site-wide stream is delivered to onAlarmEvent with full enrichment.
var ts = DateTimeOffset.UtcNow;
var evt = new SiteStreamEvent
{
CorrelationId = "site-corr",
AlarmChanged = new AlarmStateUpdate
{
InstanceUniqueName = "Site1.Motor01",
AlarmName = "T01.Hi",
State = AlarmStateEnum.AlarmStateActive,
Priority = 700,
Timestamp = Timestamp.FromDateTimeOffset(ts),
Kind = "NativeOpcUa",
Active = true,
SourceReference = "T01.Hi"
}
};
var alarm = SiteStreamGrpcClient.ConvertToAlarmEvent(evt);
Assert.NotNull(alarm);
Assert.Equal("Site1.Motor01", alarm!.InstanceUniqueName);
Assert.Equal("T01.Hi", alarm.AlarmName);
Assert.Equal(AlarmState.Active, alarm.State);
Assert.Equal(AlarmKind.NativeOpcUa, alarm.Kind);
Assert.Equal("T01.Hi", alarm.SourceReference);
}
[Fact]
public void ConvertToAlarmEvent_AttributeChanged_ReturnsNull()
{
// Attribute events must never appear on the alarm-only site-wide stream; if one
// somehow arrives it is defensively filtered out rather than delivered or thrown.
var evt = new SiteStreamEvent
{
CorrelationId = "site-corr",
AttributeChanged = new AttributeValueUpdate
{
InstanceUniqueName = "Site1.Pump01",
AttributePath = "Modules.IO",
AttributeName = "Temperature",
Value = "42.5",
Quality = Quality.Good,
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
}
};
Assert.Null(SiteStreamGrpcClient.ConvertToAlarmEvent(evt));
}
[Fact]
public void ConvertToAlarmEvent_UnknownEvent_ReturnsNull()
{
var evt = new SiteStreamEvent { CorrelationId = "site-corr" };
Assert.Null(SiteStreamGrpcClient.ConvertToAlarmEvent(evt));
}
[Fact]
public async Task SubscribeSiteAsync_OnTestOnlyClient_Throws()
{
// Guards against subscribing on a channel-less test double, mirroring SubscribeAsync.
var client = SiteStreamGrpcClient.CreateForTesting();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
client.SubscribeSiteAsync("corr", _ => { }, _ => { }, CancellationToken.None));
}
// --- Communication-003 regression tests ---
[Fact]