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(), (raw, cond) => { rememberedRaw = raw; rememberedCond = cond; }); 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(), (_, _) => { }) .ShouldBeNull(); } private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle { /// Gets the diagnostic identifier for this alarm handle. public string DiagnosticId => "fake-alarm"; } }