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 { /// Verifies that MapSeverity buckets per OPC UA Part 9 guidance. /// The OPC UA severity value (1–1000). /// The expected mapped alarm severity. [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); } /// Verifies that MapSeverity zero maps to Low. [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); } /// Verifies that SubscribeAlarmsAsync without initialize throws InvalidOperationException. [Fact] public async Task SubscribeAlarmsAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-uninit"); await Should.ThrowAsync(async () => await drv.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken)); } /// Verifies that UnsubscribeAlarmsAsync with unknown handle is noop. [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); } /// Verifies that AcknowledgeAsync without initialize throws InvalidOperationException. [Fact] public async Task AcknowledgeAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-uninit"); await Should.ThrowAsync(async () => await drv.AcknowledgeAsync( [new AlarmAcknowledgeRequest("ns=2;s=Src", "ns=2;s=Cond", "operator ack")], TestContext.Current.CancellationToken)); } /// Verifies that AcknowledgeAsync with empty batch is noop even without init. [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 ]; /// The host hands SubscribeAlarmsAsync RawPaths; the index must be keyed by the UPSTREAM source id. [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"); } /// A ref the authored table cannot resolve is kept verbatim (direct upstream-id use stays valid). [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"); } /// The emitted ConditionId must be the RawPath - DriverHostActor routes on (driver, RawPath). [Fact] public void ProjectEvent_reports_the_RawPath_as_ConditionId_and_remembers_the_condition_node() { var index = new Dictionary { ["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"); } /// An event from a source nobody authored is filtered out. [Fact] public void ProjectEvent_drops_an_unauthored_source() { var index = new Dictionary { ["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(); } /// /// 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 (nsu=) on both sides. /// [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"); } /// /// 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. /// [Fact] public void ProjectEvent_matches_a_tag_authored_on_the_condition_node() { var index = new Dictionary { ["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"); } /// /// 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 /// BadEventIdUnknown, and the two can never come from different conditions. /// [Fact] public void ProjectEvent_remembers_the_condition_with_its_EventId() { var index = new Dictionary { ["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. ---- /// ActiveState/Id and AckedState/Id are read from the two trailing select clauses. [Fact] public void ProjectEvent_reads_the_condition_state() { var index = new Dictionary { ["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)); } /// The transition sequence drives a projector from the driver's last-told state to the event's. [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])] // Gitea #657: inactive+unacked is ONE atomic InactiveUnacknowledged (never an Active flicker), except from // Active+unacked, where a plain Clear already lands there. [InlineData(false, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })] [InlineData(true, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })] 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); } /// /// 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). /// [Theory] [InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })] [InlineData(false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })] [InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })] [InlineData(false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })] 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); } /// /// Gitea #657 — on first sight the HOST may hold any state (it retains a still-deployed condition across a /// respawn), so the first-sight kinds must land on the upstream's state from EVERY host state. Pre-fix an /// inactive+acked target emitted nothing state-bearing, leaving a retained-Active host condition Active. /// [Fact] public void First_sight_kinds_reach_the_target_from_every_host_state() { bool[] b = [false, true]; foreach (var ha in b) foreach (var hk in b) foreach (var ta in b) foreach (var tk in b) { var kinds = OpcUaClientDriver.AlarmTransitions((false, true), (ta, tk), priorKnown: false); var s = (ha, hk); foreach (var k in kinds) s = Simulate(s, k); s.ShouldBe((ta, tk), $"host ({ha},{hk}) -> upstream ({ta},{tk}) via [{string.Join(",", kinds)}]"); kinds.ShouldNotContain(AlarmTransitionKind.Unspecified); kinds.ShouldNotContain(AlarmTransitionKind.Raise, "a first sight must not keep a stale host ack"); } } // ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ---- /// The host projector's semantics, restated: the only thing the driver's kinds act upon. 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.InactiveUnacknowledged => (false, false), AlarmTransitionKind.Acknowledge => (s.Active, true), AlarmTransitionKind.Clear => (false, s.Acked), _ => s, }; /// /// 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. /// [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 TripIndex = new() { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" }; private static List Record(OpcUaClientDriver drv) { var seen = new List(); drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); }; return seen; } /// The remembered EventId is a COPY — the decoded notification's array is not ours to alias. [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 }); } /// /// 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. /// [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(); } /// RefreshStart/RefreshEnd are markers around a ConditionRefresh, never alarms — even pass-through. [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(), 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; } /// /// 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). /// [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"); } /// /// 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. /// [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), ]); } /// /// Gitea #657 — first sight of an INACTIVE upstream condition: inactive+unacked is ONE flagged /// InactiveUnacknowledged (pre-fix a Retrigger then a Clear — clients saw it Active again); inactive+acked is /// a flagged Clear then a flagged Acknowledge (pre-fix an Unspecified the host wrote as "Activated"). /// [Fact] public void First_sight_of_an_inactive_condition_is_restated_atomically() { using var unacked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657a"); var seenA = Record(unacked); unacked.RouteAlarmEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle()); seenA.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.InactiveUnacknowledged, true)]); using var acked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657b"); var seenB = Record(acked); acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle()); seenB.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([ (AlarmTransitionKind.Clear, true), (AlarmTransitionKind.Acknowledge, true), ]); // Positive control: once known, the identical refresh copy emits nothing. acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle()); seenB.Count.ShouldBe(2); } /// The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it. [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); } /// /// 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. /// [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]); } /// A server without ConditionRefresh is tolerated; anything else is a real failure. [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 { /// Gets the diagnostic identifier for this alarm handle. public string DiagnosticId => "fake-alarm"; } }