worker(alarms): SubtagAlarmConsumer synthesizing degraded transitions; dispatcher propagates Degraded
This commit is contained in:
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user