worker(alarms): SubtagAlarmConsumer synthesizing degraded transitions; dispatcher propagates Degraded

This commit is contained in:
Joseph Doherty
2026-06-13 09:35:49 -04:00
parent 1e2ed6d1ea
commit 4bd757a136
3 changed files with 531 additions and 1 deletions
@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using Xunit;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag
/// fallback advises the observable subtags, synthesizes degraded
/// transitions with stable synthetic GUIDs, routes acknowledgments to the
/// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/>
/// stands in for the live MXAccess subtag source so this needs no AVEVA
/// install.
/// </summary>
public sealed class SubtagAlarmConsumerTests
{
private const string Reference = "Galaxy!TestArea.Tank01.Level.HiHi";
private const string ActiveSubtag = "Tank01.Level.HiHi.InAlarm";
private const string AckedSubtag = "Tank01.Level.HiHi.Acked";
private const string AckCommentSubtag = "Tank01.Level.HiHi.AckComment";
private const string PrioritySubtag = "Tank01.Level.HiHi.Priority";
private static AlarmSubtagTarget BuildTarget()
{
return new AlarmSubtagTarget
{
AlarmFullReference = Reference,
SourceObjectReference = "Tank01",
ActiveSubtag = ActiveSubtag,
AckedSubtag = AckedSubtag,
AckCommentSubtag = AckCommentSubtag,
PrioritySubtag = PrioritySubtag,
};
}
private static SubtagAlarmConsumer BuildConsumer(FakeSource source)
{
return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
}
/// <summary>Verifies Subscribe advises the active, acked, and priority subtags but not the ack-comment subtag.</summary>
[Fact]
public void Subscribe_AdvisesActiveAndAckedSubtags()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
Assert.Contains(ActiveSubtag, source.Advised);
Assert.Contains(AckedSubtag, source.Advised);
Assert.Contains(PrioritySubtag, source.Advised);
Assert.DoesNotContain(AckCommentSubtag, source.Advised);
}
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
[Fact]
public void ValueChange_RaisesDegradedSynthesizedTransition()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
MxAlarmTransitionEvent? emitted = null;
consumer.AlarmTransitionEmitted += (_, e) => emitted = e;
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
Assert.NotNull(emitted);
Assert.Equal(MxAlarmStateKind.UnackAlm, emitted!.Record.State);
Assert.True(emitted.Record.Degraded);
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
}
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary>
[Fact]
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("operator ack", source.LastWrite!.Value.Value);
}
/// <summary>Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.</summary>
[Fact]
public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName(
alarmName: "DoesNotExist.NoSuchAlarm",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
}
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
[Fact]
public void SnapshotActiveAlarms_StampsDegradedAndGuid()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
IReadOnlyList<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms();
Assert.Single(snapshot);
Assert.True(snapshot[0].Degraded);
Assert.NotEqual(Guid.Empty, snapshot[0].AlarmGuid);
}
/// <summary>Verifies the synthetic GUID on the emitted transition equals the GUID in the snapshot for the same alarm.</summary>
[Fact]
public void SameReference_SyntheticGuidStableAcrossTransitionAndSnapshot()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
MxAlarmTransitionEvent? emitted = null;
consumer.AlarmTransitionEmitted += (_, e) => emitted = e;
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
IReadOnlyList<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms();
Assert.NotNull(emitted);
Assert.Single(snapshot);
Assert.Equal(emitted!.Record.AlarmGuid, snapshot[0].AlarmGuid);
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
}
private sealed class FakeSource : ISubtagAlarmSource
{
/// <summary>Raised when an advised subtag reports a new value.</summary>
public event EventHandler<SubtagValueChange>? ValueChanged;
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
public List<string> Advised { get; } = new List<string>();
/// <summary>Gets the most recent (address, value) pair passed to <see cref="Write"/>.</summary>
public (string Address, object? Value)? LastWrite { get; private set; }
/// <summary>Records the advised subtag addresses.</summary>
/// <param name="itemAddresses">The subtag references to advise.</param>
public void Advise(IReadOnlyCollection<string> itemAddresses)
{
Advised.AddRange(itemAddresses);
}
/// <summary>Records the most recent write.</summary>
/// <param name="itemAddress">The subtag reference to write.</param>
/// <param name="value">The value to write.</param>
public void Write(string itemAddress, object? value)
{
LastWrite = (itemAddress, value);
}
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
/// <param name="address">The subtag address whose value changed.</param>
/// <param name="value">The new value.</param>
/// <param name="timestampUtc">The UTC timestamp of the change.</param>
public void Raise(string address, object? value, DateTime timestampUtc)
{
ValueChanged?.Invoke(this, new SubtagValueChange
{
ItemAddress = address,
Value = value,
TimestampUtc = timestampUtc,
});
}
/// <inheritdoc />
public void Dispose()
{
}
}
}