Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs
T
Joseph Doherty 01bcca992c feat(alarms): thread an additive AckTime through the native-alarm mirror
MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.

Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.

Provenance is honest, never fabricated:
  - OPC UA A&C supplies a TRUE ack instant, so we now select it:
    AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
    index 18, APPENDED so the positional reads at 0-17 keep their meaning.
    Servers that omit the field fall back to the event's own Time.
  - MxAccess Gateway supplies none, so the ack transition's own timestamp is
    used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
    re-subscribe snapshot restores one from LastTransitionTimestamp rather
    than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.

Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.

Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. That table is RegisterReplicated in
SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at
registration time, so an additive JSON property changes no schema, no triggers
and no replication contract; metadata_json is exactly the extension point UA4
introduced for this. Rows written before the field deserialize it as null.

Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
2026-08-01 13:12:04 -04:00

391 lines
16 KiB
C#

using System.Threading.Channels;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using AlarmState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
public class StreamRelayActorTests : TestKit
{
[Fact]
public void RelaysAttributeValueChanged_ToProtoEvent()
{
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var correlationId = "corr-attr-1";
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor(correlationId, channel.Writer)));
var timestamp = new DateTimeOffset(2026, 3, 21, 10, 30, 0, TimeSpan.Zero);
var domainEvent = new AttributeValueChanged(
"Site1.Pump01", "Modules.Pressure", "CurrentPSI", 42.5, "Good", timestamp);
actor.Tell(domainEvent);
var success = channel.Reader.TryRead(out var protoEvent);
if (!success)
{
// Give a moment for async processing
Thread.Sleep(500);
success = channel.Reader.TryRead(out protoEvent);
}
Assert.True(success, "Expected a proto event on the channel");
Assert.NotNull(protoEvent);
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, protoEvent.EventCase);
Assert.Equal(correlationId, protoEvent.CorrelationId);
var attr = protoEvent.AttributeChanged;
Assert.Equal("Site1.Pump01", attr.InstanceUniqueName);
Assert.Equal("Modules.Pressure", attr.AttributePath);
Assert.Equal("CurrentPSI", attr.AttributeName);
Assert.Equal("42.5", attr.Value);
Assert.Equal(Quality.Good, attr.Quality);
Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), attr.Timestamp);
}
[Fact]
public void RelaysAlarmStateChanged_ToProtoEvent()
{
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var correlationId = "corr-alarm-1";
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor(correlationId, channel.Writer)));
var timestamp = new DateTimeOffset(2026, 3, 21, 11, 0, 0, TimeSpan.Zero);
var raiseTime = new DateTimeOffset(2026, 3, 21, 10, 0, 0, TimeSpan.Zero);
var domainEvent = new AlarmStateChanged(
"Site1.Pump01", "T01.Hi", AlarmState.Active, 900, timestamp)
{
Kind = AlarmKind.NativeMxAccess,
SourceReference = "T01.Hi",
AlarmTypeName = "AnalogLimit.Hi",
Category = "Process",
OperatorUser = "op1",
OperatorComment = "ack",
OriginalRaiseTime = raiseTime,
CurrentValue = "92",
LimitValue = "90",
Condition = new AlarmConditionState(
Active: true, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 900)
};
actor.Tell(domainEvent);
var success = channel.Reader.TryRead(out var protoEvent);
if (!success)
{
Thread.Sleep(500);
success = channel.Reader.TryRead(out protoEvent);
}
Assert.True(success, "Expected a proto event on the channel");
Assert.NotNull(protoEvent);
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, protoEvent.EventCase);
Assert.Equal(correlationId, protoEvent.CorrelationId);
var alarm = protoEvent.AlarmChanged;
Assert.Equal("Site1.Pump01", alarm.InstanceUniqueName);
Assert.Equal("T01.Hi", alarm.AlarmName);
Assert.Equal(AlarmStateEnum.AlarmStateActive, alarm.State);
Assert.Equal(900, alarm.Priority);
Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), alarm.Timestamp);
// Native enrichment mapped out.
Assert.Equal("NativeMxAccess", alarm.Kind);
Assert.True(alarm.Active);
Assert.True(alarm.Acknowledged);
Assert.Equal("OneShotShelved", alarm.ShelveState);
Assert.False(alarm.Suppressed);
Assert.Equal("T01.Hi", alarm.SourceReference);
Assert.Equal("AnalogLimit.Hi", alarm.AlarmTypeName);
Assert.Equal("Process", alarm.Category);
Assert.Equal("op1", alarm.OperatorUser);
Assert.Equal("ack", alarm.OperatorComment);
Assert.Equal(Timestamp.FromDateTimeOffset(raiseTime), alarm.OriginalRaiseTime);
Assert.Equal("92", alarm.CurrentValue);
Assert.Equal("90", alarm.LimitValue);
}
[Fact]
public void RelaysAlarmStateChanged_NativeBindingLinkage_SurvivesFullRoundTrip()
{
// DV-1: the native source-binding canonical name must pack into AlarmStateUpdate
// (StreamRelayActor) and unpack back out (SiteStreamGrpcClient.ConvertToDomainEvent)
// — the full cross-process round trip for a real (non-placeholder) native alarm.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-native-binding", channel.Writer)));
var timestamp = new DateTimeOffset(2026, 3, 21, 11, 0, 0, TimeSpan.Zero);
var domainEvent = new AlarmStateChanged(
"Site1.Motor01", "Motor1.MotorAlarms.Hi", AlarmState.Active, 900, timestamp)
{
Kind = AlarmKind.NativeOpcUa,
SourceReference = "Motor1.MotorAlarms.Hi",
NativeSourceCanonicalName = "Motor1.MotorAlarms",
IsConfiguredPlaceholder = false,
Condition = new AlarmConditionState(
Active: true, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
};
actor.Tell(domainEvent);
var success = channel.Reader.TryRead(out var protoEvent);
if (!success)
{
Thread.Sleep(500);
success = channel.Reader.TryRead(out protoEvent);
}
Assert.True(success, "Expected a proto event on the channel");
Assert.NotNull(protoEvent);
// Packed onto the wire frame by StreamRelayActor.
Assert.Equal("Motor1.MotorAlarms", protoEvent.AlarmChanged.NativeSourceCanonicalName);
Assert.False(protoEvent.AlarmChanged.IsConfiguredPlaceholder);
// Unpacked back into the domain record by SiteStreamGrpcClient.
var roundTripped = Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(protoEvent));
Assert.Equal("Motor1.MotorAlarms", roundTripped.NativeSourceCanonicalName);
Assert.False(roundTripped.IsConfiguredPlaceholder);
}
[Fact]
public void RelaysAlarmStateChanged_AckTime_SurvivesFullRoundTrip()
{
// MES alarm-status API §6.4: AlarmStateUpdate field 24. An acknowledged mirrored
// condition must reach central with its ack instant intact, and an unacknowledged
// one must arrive as null — an absent proto Timestamp, not the epoch.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-acktime", channel.Writer)));
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
var acked = new AlarmStateChanged(
"Site1.Motor01", "Motor1.MotorAlarms.Hi", AlarmState.Active, 900,
new DateTimeOffset(2026, 8, 1, 12, 5, 0, TimeSpan.Zero))
{
Kind = AlarmKind.NativeOpcUa,
SourceReference = "Motor1.MotorAlarms.Hi",
NativeSourceCanonicalName = "Motor1.MotorAlarms",
AckTime = ackedAt,
Condition = new AlarmConditionState(
Active: true, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
};
actor.Tell(acked);
var ackedProto = ReadProtoEvent(channel);
Assert.Equal(Timestamp.FromDateTimeOffset(ackedAt), ackedProto.AlarmChanged.AckTime);
Assert.Equal(
ackedAt,
Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(ackedProto)).AckTime);
actor.Tell(acked with
{
AckTime = null,
Condition = new AlarmConditionState(
Active: true, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
});
var unackedProto = ReadProtoEvent(channel);
Assert.Null(unackedProto.AlarmChanged.AckTime);
Assert.Null(
Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(unackedProto)).AckTime);
}
/// <summary>
/// Reads the next relayed proto event, retrying once after a short pause because the
/// relay actor writes to the channel asynchronously (mirrors the existing round-trip
/// tests' read pattern).
/// </summary>
private static SiteStreamEvent ReadProtoEvent(Channel<SiteStreamEvent> channel)
{
if (!channel.Reader.TryRead(out var protoEvent))
{
Thread.Sleep(500);
Assert.True(channel.Reader.TryRead(out protoEvent), "Expected a proto event on the channel");
}
Assert.NotNull(protoEvent);
return protoEvent!;
}
[Fact]
public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder()
{
// Placeholder rows are a Debug View snapshot-only concept emitted by
// InstanceActor.BuildAlarmStatesSnapshot. They are never a real alarm
// transition; their timestamp may be DateTimeOffset.MinValue (the Protobuf
// Timestamp boundary). The relay must drop them without writing to the channel.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-placeholder-drop", channel.Writer)));
var domainEvent = new AlarmStateChanged(
"Site1.Tank01", "Tank1.Levels.Hi", AlarmState.Normal, 500,
DateTimeOffset.MinValue)
{
Kind = AlarmKind.NativeOpcUa,
NativeSourceCanonicalName = "Tank1.Levels",
IsConfiguredPlaceholder = true,
Condition = new AlarmConditionState(
Active: false, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 0)
};
actor.Tell(domainEvent);
// Allow time for the actor to process the message.
Thread.Sleep(500);
// Nothing must have been written — placeholder is silently dropped.
Assert.False(channel.Reader.TryRead(out _),
"Placeholder AlarmStateChanged must not be relayed to the gRPC stream");
}
[Fact]
public void SetsCorrelationId_OnAllEvents()
{
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var correlationId = "corr-multi-42";
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor(correlationId, channel.Writer)));
var ts = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Inst1", "Path", "Name", 1, "Good", ts));
actor.Tell(new AlarmStateChanged("Inst1", "Alarm1", AlarmState.Normal, 1, ts));
actor.Tell(new AttributeValueChanged("Inst2", "Path2", "Name2", null, "Bad", ts));
// Allow messages to process
Thread.Sleep(500);
var events = new List<SiteStreamEvent>();
while (channel.Reader.TryRead(out var evt))
{
events.Add(evt);
}
Assert.Equal(3, events.Count);
Assert.All(events, e => Assert.Equal(correlationId, e.CorrelationId));
}
[Fact]
public void DropsEvent_WhenChannelFull()
{
var channel = Channel.CreateBounded<SiteStreamEvent>(new BoundedChannelOptions(1)
{
FullMode = BoundedChannelFullMode.Wait
});
var correlationId = "corr-drop-1";
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor(correlationId, channel.Writer)));
var ts = DateTimeOffset.UtcNow;
// Fill the channel with one item directly
var filler = new SiteStreamEvent { CorrelationId = "filler" };
Assert.True(channel.Writer.TryWrite(filler));
// Send another event — should be dropped (channel full), no exception
actor.Tell(new AttributeValueChanged("Inst1", "Path", "Name", 1, "Good", ts));
// Allow message to process
Thread.Sleep(500);
// Channel should still have exactly 1 item (the filler)
Assert.True(channel.Reader.TryRead(out var item));
Assert.Equal("filler", item.CorrelationId);
Assert.False(channel.Reader.TryRead(out _));
}
[Theory]
[InlineData("Good", Quality.Good)]
[InlineData("Uncertain", Quality.Uncertain)]
[InlineData("Bad", Quality.Bad)]
[InlineData("Unknown", Quality.Unspecified)]
[InlineData("", Quality.Unspecified)]
[InlineData("good", Quality.Unspecified)]
public void MapsQualityString_ToProtoEnum(string qualityString, Quality expectedProto)
{
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr", channel.Writer)));
var ts = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", 1, qualityString, ts));
Thread.Sleep(500);
Assert.True(channel.Reader.TryRead(out var evt));
Assert.Equal(expectedProto, evt.AttributeChanged.Quality);
}
[Fact]
public void NullValue_MapsToEmptyString()
{
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr", channel.Writer)));
var ts = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", null, "Good", ts));
// Poll for the relayed event with a generous timeout instead of a single
// fixed Thread.Sleep + one-shot TryRead. Under full-suite CPU
// oversubscription the actor may not have processed the Tell within a
// fixed 500ms window, racing the read; AwaitAssert retries until the
// channel has the item (or the generous deadline elapses).
SiteStreamEvent? evt = null;
AwaitAssert(
() => Assert.True(channel.Reader.TryRead(out evt), "Expected a relayed proto event on the channel"),
TimeSpan.FromSeconds(5));
Assert.NotNull(evt);
Assert.Equal("", evt.AttributeChanged.Value);
}
[Fact]
public void ListValue_EncodesAsJsonArray()
{
// List attributes must cross the wire as JSON, not as a comma-joined display string.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-list", channel.Writer)));
var ts = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Inst", "Path", "Tags",
new List<string> { "a", "b" }, "Good", ts));
Thread.Sleep(500);
Assert.True(channel.Reader.TryRead(out var evt));
Assert.Equal("[\"a\",\"b\"]", evt.AttributeChanged.Value);
}
[Fact]
public void ScalarStringValue_PassesThroughUnchanged()
{
// Scalar strings must not be double-encoded.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-scalar", channel.Writer)));
var ts = DateTimeOffset.UtcNow;
actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", "x", "Good", ts));
Thread.Sleep(500);
Assert.True(channel.Reader.TryRead(out var evt));
Assert.Equal("x", evt.AttributeChanged.Value);
}
}