b39bb6794d
- 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
469 lines
23 KiB
C#
469 lines
23 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 });
|
||
}
|
||
|
||
// ---- #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";
|
||
}
|
||
}
|