Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs
T
Joseph Doherty 8f5b265db8 fix(opcuaclient): native-alarm ConditionRefresh + transition table, branch, dedup and thread-safety fixes (#619 re-review)
Re-review of 3de2cc3b/454775c6:
- AlarmTransitions could not reach (inactive, unacked): (active,acked)->(inactive,unacked) emitted Clear
  (host stayed acked) and a first-seen cleared-unacked condition emitted nothing. The table is now total;
  an exhaustive test replays every (prior, target) pair through the projector's semantics.
- The per-RawPath read-compute-emit was a lock-free TryGetValue/set across SDK publish threads; it now runs
  under one lock with the projection and emission.
- Condition BRANCH events (non-null BranchId) were projected (flipping the trunk) and remembered as the ack
  target (acking the branch); they are dropped. BranchId is select clause 9.
- EventId is copied, not aliased from the decoded notification.
- An unchanged state re-emitted Unspecified; it now emits nothing unless message/severity changed (an
  Unspecified projects from the host projector's default after a redeploy reset it).
- Exact upstream severity rides OpcUaSeverity (#626) instead of being re-bucketed.
- ShutdownAsync no longer forgets the last-told state: a reinit re-subscribes the same set and the refresh
  would otherwise re-raise every acked alarm. A CHANGED set (host rebuilt) forgets it.

ConditionRefresh: called after the alarm event item is created, after an in-place reconnect (subscriptions
found on the new session by display name, since a recreate clones them) and on RefreshRequired. Refresh
markers are never projected. BadNotSupported/BadMethodInvalid/... logs once. Wire test: a condition already
active before subscribe is reported Raise; a release+resubscribe refresh re-raises nothing (falsified by
disabling the refresh call).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:13:54 -04:00

424 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientAlarmTests
{
/// <summary>Verifies that MapSeverity buckets per OPC UA Part 9 guidance.</summary>
/// <param name="opcSev">The OPC UA severity value (1–1000).</param>
/// <param name="expected">The expected mapped alarm severity.</param>
[Theory]
[InlineData((ushort)1, AlarmSeverity.Low)]
[InlineData((ushort)200, AlarmSeverity.Low)]
[InlineData((ushort)201, AlarmSeverity.Medium)]
[InlineData((ushort)500, AlarmSeverity.Medium)]
[InlineData((ushort)501, AlarmSeverity.High)]
[InlineData((ushort)800, AlarmSeverity.High)]
[InlineData((ushort)801, AlarmSeverity.Critical)]
[InlineData((ushort)1000, AlarmSeverity.Critical)]
public void MapSeverity_buckets_per_OPC_UA_Part_9_guidance(ushort opcSev, AlarmSeverity expected)
{
OpcUaClientDriver.MapSeverity(opcSev).ShouldBe(expected);
}
/// <summary>Verifies that MapSeverity zero maps to Low.</summary>
[Fact]
public void MapSeverity_zero_maps_to_Low()
{
// 0 isn't in OPC UA's 1-1000 range but we handle it gracefully as Low.
OpcUaClientDriver.MapSeverity(0).ShouldBe(AlarmSeverity.Low);
}
/// <summary>Verifies that SubscribeAlarmsAsync without initialize throws InvalidOperationException.</summary>
[Fact]
public async Task SubscribeAlarmsAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken));
}
/// <summary>Verifies that UnsubscribeAlarmsAsync with unknown handle is noop.</summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_with_unknown_handle_is_noop()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-unknown");
// Parallels the subscribe handle path — session-drop races shouldn't crash the caller.
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken);
}
/// <summary>Verifies that AcknowledgeAsync without initialize throws InvalidOperationException.</summary>
[Fact]
public async Task AcknowledgeAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("ns=2;s=Src", "ns=2;s=Cond", "operator ack")],
TestContext.Current.CancellationToken));
}
/// <summary>Verifies that AcknowledgeAsync with empty batch is noop even without init.</summary>
[Fact]
public async Task AcknowledgeAsync_with_empty_batch_is_noop_even_without_init()
{
// Empty batch short-circuits before touching the session, so it's safe pre-init. This
// keeps batch-ack callers from needing to guard the list size themselves.
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-empty");
await drv.AcknowledgeAsync([], TestContext.Current.CancellationToken);
}
// ---- #619: v3 RawPath <-> upstream-source translation on the native-alarm path ----
private static VariantCollection EventFields(string sourceNode, string conditionNode) =>
[
new Variant(new byte[] { 1 }), // EventId
new Variant(new NodeId("TripAlarmType", 3)), // EventType
new Variant(NodeId.Parse(sourceNode)), // SourceNode
new Variant(new LocalizedText("tripped")), // Message
new Variant((ushort)700), // Severity
new Variant(DateTime.UtcNow), // Time
new Variant(NodeId.Parse(conditionNode)), // ConditionId
];
/// <summary>The host hands SubscribeAlarmsAsync RawPaths; the index must be keyed by the UPSTREAM source id.</summary>
[Fact]
public void BuildAlarmSourceIndex_keys_the_upstream_source_id_to_the_RawPath()
{
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
["Plant/opc/plc/Trip1"],
raw => raw == "Plant/opc/plc/Trip1" ? "ns=3;s=Alarms.Trip1" : null);
index["ns=3;s=Alarms.Trip1"].ShouldBe("Plant/opc/plc/Trip1");
}
/// <summary>A ref the authored table cannot resolve is kept verbatim (direct upstream-id use stays valid).</summary>
[Fact]
public void BuildAlarmSourceIndex_keeps_an_unresolvable_ref_as_its_own_key()
{
var index = OpcUaClientDriver.BuildAlarmSourceIndex(["ns=3;s=Direct"], _ => null);
index["ns=3;s=Direct"].ShouldBe("ns=3;s=Direct");
}
/// <summary>The emitted ConditionId must be the RawPath - DriverHostActor routes on (driver, RawPath).</summary>
[Fact]
public void ProjectEvent_reports_the_RawPath_as_ConditionId_and_remembers_the_condition_node()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
string? rememberedRaw = null, rememberedCond = null;
var e = OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
n => n.ToString(), (raw, target) => { rememberedRaw = raw; rememberedCond = target.ConditionRef; });
e.ShouldNotBeNull();
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
e.SourceNodeId.ShouldBe("Plant/opc/plc/Trip1");
rememberedRaw.ShouldBe("Plant/opc/plc/Trip1");
rememberedCond.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
}
/// <summary>An event from a source nobody authored is filtered out.</summary>
[Fact]
public void ProjectEvent_drops_an_unauthored_source()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Other", "ns=3;s=Other.Cond"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { })
.ShouldBeNull();
}
/// <summary>
/// A remote namespace-table reorder across an in-place SDK reconnect must not orphan the alarm index:
/// it is built once at subscribe time, while events keep arriving on the (transferred) subscription with
/// the NEW namespace indexes. Keys are therefore server-stable (<c>nsu=</c>) on both sides.
/// </summary>
[Fact]
public void ProjectEvent_matches_across_a_namespace_reorder()
{
var before = new NamespaceTable(); before.Append("urn:other"); before.Append("urn:plc"); // urn:plc = 2
var after = new NamespaceTable(); after.Append("urn:new"); after.Append("urn:other"); after.Append("urn:plc"); // urn:plc = 3
var mapBefore = NamespaceMap.FromTable(before);
var mapAfter = NamespaceMap.FromTable(after);
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
["Plant/opc/plc/Trip1"], _ => OpcUaClientDriver.AlarmIndexKey(mapBefore, new NodeId("Alarms.Trip1", 2)));
var e = OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
n => OpcUaClientDriver.AlarmIndexKey(mapAfter, n), (_, _) => { });
e.ShouldNotBeNull();
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
}
/// <summary>
/// A source with several conditions (Hi + HiHi on one variable) can only be told apart by the CONDITION
/// node, so a tag authored on the condition node matches on the event's ConditionId — 1:1, no collapse.
/// </summary>
[Fact]
public void ProjectEvent_matches_a_tag_authored_on_the_condition_node()
{
var index = new Dictionary<string, string>
{
["ns=3;s=Tank.HiHi"] = "Plant/opc/plc/HiHi",
["ns=3;s=Tank.Hi"] = "Plant/opc/plc/Hi",
};
var e = OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Tank", "ns=3;s=Tank.HiHi"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { });
e.ShouldNotBeNull();
e.ConditionId.ShouldBe("Plant/opc/plc/HiHi");
}
/// <summary>
/// The ack target pairs the condition node WITH the EventId of the event that named it, so Acknowledge
/// carries the real EventId (Part 9 §5.7.3) instead of an empty one a strict server rejects
/// <c>BadEventIdUnknown</c>, and the two can never come from different conditions.
/// </summary>
[Fact]
public void ProjectEvent_remembers_the_condition_with_its_EventId()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
OpcUaClientDriver.AlarmAckTarget? target = null;
OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
n => n.ToString(), (_, t) => target = t);
target.ShouldNotBeNull();
target.Value.ConditionRef.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
target.Value.EventId.ShouldBe(new byte[] { 1 });
}
// ---- #619 review: the host's NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind; an
// Unspecified kind keeps the prior state (inactive+acked when fresh), so without a Kind no upstream alarm
// could ever show active. ----
/// <summary>ActiveState/Id and AckedState/Id are read from the two trailing select clauses.</summary>
[Fact]
public void ProjectEvent_reads_the_condition_state()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
var fields = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
fields.Add(new Variant(true)); // ActiveState/Id
fields.Add(new Variant(false)); // AckedState/Id
OpcUaClientDriver.ProjectEvent(fields, index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { },
out var state);
state.ShouldBe((true, false));
}
/// <summary>The transition sequence drives a projector from the driver's last-told state to the event's.</summary>
[Theory]
[InlineData(false, true, true, false, new[] { AlarmTransitionKind.Raise })]
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, true, true, new[] { AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, false, false, new[] { AlarmTransitionKind.Clear })]
[InlineData(true, true, false, true, new[] { AlarmTransitionKind.Clear })]
[InlineData(false, false, false, true, new[] { AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
public void AlarmTransitions_move_prior_to_target(
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
}
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
private static (bool Active, bool Acked) Simulate((bool Active, bool Acked) s, AlarmTransitionKind k) => k switch
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.Acknowledge => (s.Active, true),
AlarmTransitionKind.Clear => (false, s.Acked),
_ => s,
};
/// <summary>
/// Exhaustive over all 16 (prior, target) pairs: replaying the emitted kinds through the projector's
/// semantics lands on the target, and an unchanged state emits nothing. The table this replaced reached
/// (inactive, acked) from (active, acked) → (inactive, UNACKED) wrongly, and could not express a
/// cleared-but-unacked condition first seen by a ConditionRefresh at all.
/// </summary>
[Fact]
public void AlarmTransitions_reach_every_target_from_every_prior()
{
bool[] b = [false, true];
foreach (var pa in b) foreach (var pk in b) foreach (var ta in b) foreach (var tk in b)
{
var kinds = OpcUaClientDriver.AlarmTransitions((pa, pk), (ta, tk));
var s = (pa, pk);
foreach (var k in kinds) s = Simulate(s, k);
s.ShouldBe((ta, tk), $"prior ({pa},{pk}) -> target ({ta},{tk}) via [{string.Join(",", kinds)}]");
if ((pa, pk) == (ta, tk)) kinds.ShouldBeEmpty();
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
}
}
private static VariantCollection ConditionFields(
bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null,
NodeId? branchId = null, NodeId? eventType = null)
{
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
f[0] = new Variant(eventId ?? new byte[] { 1 });
if (eventType is not null) f[1] = new Variant(eventType);
f[3] = new Variant(new LocalizedText(message));
f[4] = new Variant(severity);
f.Add(new Variant(active));
f.Add(new Variant(acked));
f.Add(branchId is null ? Variant.Null : new Variant(branchId));
return f;
}
private static readonly Dictionary<string, string> TripIndex = new() { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
private static List<AlarmEventArgs> Record(OpcUaClientDriver drv)
{
var seen = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
return seen;
}
/// <summary>The remembered EventId is a COPY — the decoded notification's array is not ours to alias.</summary>
[Fact]
public void ProjectEvent_copies_the_EventId()
{
var raw = new byte[] { 7, 8, 9 };
OpcUaClientDriver.AlarmAckTarget? target = null;
OpcUaClientDriver.ProjectEvent(ConditionFields(true, false, eventId: raw), TripIndex, new FakeAlarmHandle(),
n => n.ToString(), (_, t) => target = t);
raw[0] = 0;
target!.Value.EventId.ShouldBe(new byte[] { 7, 8, 9 });
}
/// <summary>
/// A condition BRANCH event (BranchId non-null) reports a previous state of the condition and carries its
/// own EventId — projecting it would flip the trunk's state, and remembering it would ack the branch.
/// </summary>
[Fact]
public void ProjectEvent_ignores_a_condition_branch_event()
{
var remembered = false;
OpcUaClientDriver.ProjectEvent(
ConditionFields(false, false, branchId: new NodeId(Guid.NewGuid(), 3)), TripIndex, new FakeAlarmHandle(),
n => n.ToString(), (_, _) => remembered = true)
.ShouldBeNull();
remembered.ShouldBeFalse();
// Positive control: the same event with a null BranchId (the trunk) projects.
OpcUaClientDriver.ProjectEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle(),
n => n.ToString(), (_, _) => { })
.ShouldNotBeNull();
}
/// <summary>RefreshStart/RefreshEnd are markers around a ConditionRefresh, never alarms — even pass-through.</summary>
[Fact]
public void Refresh_markers_are_never_projected_as_alarms()
{
foreach (var type in new[] { ObjectTypeIds.RefreshStartEventType, ObjectTypeIds.RefreshEndEventType, ObjectTypeIds.RefreshRequiredEventType })
{
var fields = EventFields("i=2253", "i=0");
fields[1] = new Variant(type);
OpcUaClientDriver.ProjectEvent(fields, new Dictionary<string, string>(), new FakeAlarmHandle(),
n => n.ToString(), (_, _) => { }).ShouldBeNull($"{type} is a refresh marker");
}
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshRequiredEventType)).ShouldBeTrue();
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse();
}
private static VariantCollection EventFieldsOfType(NodeId type)
{
var f = EventFields("i=2253", "i=0");
f[1] = new Variant(type);
return f;
}
/// <summary>
/// A ConditionRefresh re-delivers every retained condition's CURRENT state. One the host was already told
/// must emit nothing — no re-raise (which would un-ack an acked alarm) and no Unspecified (which a host
/// projector reset by a redeploy would project from its inactive default).
/// </summary>
[Fact]
public void A_refresh_of_an_already_reported_state_emits_nothing()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-dedup");
var seen = Record(drv);
var handle = new FakeAlarmHandle();
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
seen.Count.ShouldBe(2);
// Positive control: a genuine message change on an unchanged state still reaches the host.
drv.RouteAlarmEvent(ConditionFields(true, true, message: "worse"), TripIndex, handle);
seen.Count.ShouldBe(3);
seen[2].Kind.ShouldBe(AlarmTransitionKind.Unspecified);
seen[2].Message.ShouldBe("worse");
}
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
[Fact]
public void The_exact_upstream_severity_is_carried()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-sev");
var seen = Record(drv);
drv.RouteAlarmEvent(ConditionFields(true, false, severity: 650), TripIndex, new FakeAlarmHandle());
seen.Single().OpcUaSeverity.ShouldBe(650);
}
/// <summary>
/// A driver re-initialise (comms-loss watchdog, Force Reconnect) re-subscribes the SAME set over nodes the
/// host did not rebuild, so the last-told state must survive it — or the refresh after it would re-raise
/// every acked active alarm (un-ack). A CHANGED set means the host rebuilt (nodes recreated inactive), so
/// there the state is forgotten and the refresh re-raises from scratch.
/// </summary>
[Fact]
public async Task Last_told_state_survives_a_reinit_of_the_same_set_but_not_a_set_change()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-reinit");
var seen = Record(drv);
var handle = new FakeAlarmHandle();
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Count.ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Count.ShouldBe(2, "same set after a reinit: the refresh copy is a duplicate");
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1", "Plant/opc/plc/Trip2"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Skip(2).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
/// <summary>A server without ConditionRefresh is tolerated; anything else is a real failure.</summary>
[Fact]
public void ConditionRefresh_unsupported_status_codes()
{
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotSupported).ShouldBeTrue();
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadMethodInvalid).ShouldBeTrue();
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotImplemented).ShouldBeTrue();
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadTimeout).ShouldBeFalse();
}
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
public string DiagnosticId => "fake-alarm";
}
}