3de2cc3ba8
The alarm index was keyed on ns=N strings resolved once at subscribe time, while an in-place SDK reconnect transfers the subscription without re-running it - a remote namespace reorder silently orphaned every alarm tag. Keys are now nsu= on both sides. Events match the authored nodeId against the event's ConditionId first, then its SourceNode, so several conditions on one source can be authored 1:1. Acknowledge passes the EventId of the event that named the remembered condition (paired, so node and EventId can never come from different conditions) instead of an empty one. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
205 lines
9.5 KiB
C#
205 lines
9.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(),
|
||
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 });
|
||
}
|
||
|
||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||
{
|
||
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
|
||
public string DiagnosticId => "fake-alarm";
|
||
}
|
||
}
|