e56926dc47
The host subscribes alarms with RawPaths and routes transitions on (driver, RawPath), but the driver filtered upstream SourceNode ids against the RawPaths and reported the upstream condition NodeId as ConditionId, so no opc-plc (or any upstream) alarm could ever reach a condition node. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
141 lines
6.5 KiB
C#
141 lines
6.5 KiB
C#
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(),
|
||
(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");
|
||
}
|
||
|
||
/// <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(), (_, _) => { })
|
||
.ShouldBeNull();
|
||
}
|
||
|
||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||
{
|
||
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
|
||
public string DiagnosticId => "fake-alarm";
|
||
}
|
||
}
|