Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs
T
Joseph Doherty b39bb6794d fix(alarms): native-alarm re-statements no longer duplicate history or hide acked alarms (#656)
- AlarmTransitionKind.ActiveAcknowledged (appended): active AND acked applied as ONE projection.
  Galaxy ActiveAcked snapshot entries and OpcUaClient inactive->(active,acked) use it; the
  projector writes 'Activated' only if not already active, 'Acknowledged' only if not acked.
- AlarmEventArgs.IsRestatement (optional, default false): set only by Galaxy snapshot entries
  and OpcUaClient first sight. A flagged Retrigger on an Active+unacked condition with the same
  message and severity, or a flagged Clear on an inactive condition, writes no /alerts row.
- NativeAlarmProjector reports the history rows per transition; ForwardNativeAlarm writes one
  row per entry. Unflagged sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged.
- Tests: projector rules each with a must-write control; OpcUaClient transition table;
  Galaxy feed/driver mapping; Galaxy + OpcUaClient end-to-end through DriverHostActor.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 23:35:50 -04:00

469 lines
23 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.ActiveAcknowledged })]
[InlineData(false, false, true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
[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);
}
/// <summary>
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
/// An acknowledged active target is ONE atomic ActiveAcknowledged (Gitea #656 — Raise-then-Acknowledge showed
/// clients an Active+unacked intermediate).
/// </summary>
[Theory]
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).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.ActiveAcknowledged => (true, true),
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);
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
// #656: only the first-sight event re-states; the ack after it is a transition this instance watched.
seen.Select(e => e.IsRestatement).ShouldBe([true, false]);
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>
/// Gitea #656 — an upstream condition first seen Active+acked goes out as ONE ActiveAcknowledged flagged as a
/// re-statement (never Raise-then-Acknowledge, whose first event showed clients an unacked alarm); every
/// event after first sight is unflagged, including a later re-activation.
/// </summary>
[Fact]
public void First_sight_of_an_acked_active_condition_is_one_restated_ActiveAcknowledged()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-656");
var seen = Record(drv);
var handle = new FakeAlarmHandle();
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.ActiveAcknowledged, true)]);
drv.RouteAlarmEvent(ConditionFields(false, true), TripIndex, handle);
drv.RouteAlarmEvent(ConditionFields(true, true, message: "again"), TripIndex, handle);
seen.Skip(1).Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
(AlarmTransitionKind.Clear, false),
(AlarmTransitionKind.ActiveAcknowledged, false),
]);
}
/// <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(1);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Count.ShouldBe(1, "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(1).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.ActiveAcknowledged]);
}
/// <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";
}
}