aba3aa7a8d
OtOpcUaNodeManager.MapSeverity re-bucketed every projected severity onto the SDK's five EventSeverity values (<800 -> 700, else 900), defeating #626's exact OpcUaSeverity: an MTConnect Fault (800) read and evented as 900; a scripted alarm graded 1000 published 900 while /alerts said 1000; a native Low (200) published 300. EventSeverity merely names points on the 1..1000 scale, so the node now carries the value clamped to 1..1000 (ToPart9Severity), at materialise, projection and in the delta gate. MTConnect wire test now asserts Fault ActiveState=true/Severity=800 and the Normal clear ActiveState=false/Retain=false/Severity=800. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
742 lines
39 KiB
C#
742 lines
39 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the F10b production binding: boot a real <see cref="OtOpcUaSdkServer"/>
|
|
/// through <see cref="OpcUaApplicationHost"/>, attach a <see cref="SdkAddressSpaceSink"/>,
|
|
/// drive <c>WriteValue</c>/<c>WriteAlarmCondition</c>/<c>RebuildAddressSpace</c>, and verify the
|
|
/// <see cref="OtOpcUaNodeManager"/> reflects the writes.
|
|
/// </summary>
|
|
public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-sink-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
|
|
[Fact]
|
|
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
|
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
|
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>The sink's <see cref="SdkAddressSpaceSink.WriteValues"/> override forwards the batch to the
|
|
/// node manager's own batched entry point — every item lands, with its own value and realm.</summary>
|
|
[Fact]
|
|
public async Task WriteValues_forwards_the_batch_to_the_node_manager()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
// Through the INTERFACE on purpose: that is the seam production uses, and it is the only call
|
|
// shape under which deleting the sink's override silently degrades to the per-value default loop
|
|
// (a concrete-typed call would simply stop compiling, which is not the regression being pinned).
|
|
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
|
|
var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
|
|
|
|
sink.WriteValues(new List<ValueWrite>
|
|
{
|
|
new("Plant/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
|
|
new("eq-1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
|
});
|
|
|
|
nm.VariableCount.ShouldBe(2);
|
|
|
|
var raw = nm.TryGetVariable("Plant/dev1/Speed", AddressSpaceRealm.Raw);
|
|
raw.ShouldNotBeNull();
|
|
raw!.Value.ShouldBe(42);
|
|
raw.Timestamp.ShouldBe(ts);
|
|
|
|
var uns = nm.TryGetVariable("eq-1/Speed", AddressSpaceRealm.Uns);
|
|
uns.ShouldNotBeNull();
|
|
uns!.Value.ShouldBe(42);
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>Pins that the sink actually OVERRIDES <c>WriteValues</c> rather than inheriting the
|
|
/// interface's per-value default loop. The values-landed assertion above cannot tell the two apart — the
|
|
/// default loop lands them too — so this uses the one place their behaviour diverges: the manager's
|
|
/// batched path isolates per item and reports a <see cref="ValueWriteBatchException"/> aggregate, whereas
|
|
/// the default loop lets the first item's <c>ArgumentException</c> escape and never reaches the tail.
|
|
/// A test seam (a recording subclass) is not available here — <see cref="SdkAddressSpaceSink"/> is sealed
|
|
/// and its <c>WriteValues</c> is not virtual — so the forward is pinned by its observable contract
|
|
/// instead, which is the stronger assertion anyway.</summary>
|
|
[Fact]
|
|
public async Task WriteValues_forward_surfaces_the_node_managers_partial_failure_contract()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
|
|
var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc);
|
|
|
|
var ex = Should.Throw<ValueWriteBatchException>(() => sink.WriteValues(new List<ValueWrite>
|
|
{
|
|
new("eq-fwd/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
|
new(string.Empty, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
|
new("eq-fwd/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
|
|
}));
|
|
|
|
ex.FailedCount.ShouldBe(1);
|
|
ex.SucceededCount.ShouldBe(2);
|
|
|
|
// The tail item landed — the default per-value loop would have thrown out on item 2 and skipped it.
|
|
var third = nm.TryGetVariable("eq-fwd/third", AddressSpaceRealm.Uns);
|
|
third.ShouldNotBeNull();
|
|
third!.Value.ShouldBe(3);
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
|
|
/// distinct from value writes.</summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
|
|
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
|
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>B4-WP2 M1: AddReference wires the cross-tree Organizes edge from a UNS reference variable
|
|
/// (source, realm=Uns) to its backing Raw node (target, realm=Raw), bidirectionally, so browse resolves
|
|
/// both ways — and is idempotent.</summary>
|
|
[Fact]
|
|
public async Task AddReference_wires_Organizes_edge_from_UNS_variable_to_Raw_node()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
// Backing Raw node (device tree) and the UNS reference variable (equipment tree).
|
|
sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp",
|
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
|
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
|
|
|
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
|
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
|
|
|
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
|
|
var rawVar = nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
|
unsVar.ShouldNotBeNull();
|
|
rawVar.ShouldNotBeNull();
|
|
|
|
// Forward Organizes on the UNS variable → Raw node; inverse (OrganizedBy) on the Raw node.
|
|
unsVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: false, rawVar.NodeId).ShouldBeTrue();
|
|
rawVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: true, unsVar.NodeId).ShouldBeTrue();
|
|
|
|
// Idempotent: a second identical call does not duplicate the edge. The UNS variable's only
|
|
// FORWARD Organizes ref is the one we added (its parent link manifests as an inverse ref).
|
|
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
|
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
|
nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
|
|
.Count(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBe(1);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>B4-WP2 M1: a missing endpoint makes AddReference a safe no-op (never throws, no edge added).</summary>
|
|
[Fact]
|
|
public async Task AddReference_with_missing_endpoint_is_a_safe_no_op()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
|
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
|
|
|
// Target does not exist ⇒ no-op, no throw.
|
|
Should.NotThrow(() => sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
|
"MAIN/does/not/exist", AddressSpaceRealm.Raw));
|
|
|
|
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
|
|
unsVar.ShouldNotBeNull();
|
|
nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
|
|
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBeFalse();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-15 review I3 — <c>RemoveVariableNode</c> must sever the INVERSE half of the cross-tree
|
|
/// <c>Organizes</c> edge it holds, not just drop its own node. <c>AddReference</c> wires the edge
|
|
/// bidirectionally (forward on the UNS reference variable, inverse on the backing RAW node), and the
|
|
/// raw node SURVIVES a scoped remove — so without this the raw node keeps a browsable, unresolvable
|
|
/// reference to a node that no longer exists. Rebuilds used to sweep these up; surgical applies made
|
|
/// rebuilds rare, so they now accumulate one per re-point for the life of the process.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RemoveVariableNode_severs_the_inverse_Organizes_edge_on_the_raw_target()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp",
|
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
|
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
|
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
|
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
|
|
|
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
|
|
unsVar.ShouldNotBeNull();
|
|
// Precondition: the raw node really does hold the inverse half.
|
|
nm.GetNodeReferences("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw)
|
|
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && r.IsInverse &&
|
|
r.TargetId == unsVar.NodeId).ShouldBeTrue();
|
|
|
|
nm.RemoveVariableNode("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns).ShouldBeTrue();
|
|
|
|
// The raw node survives…
|
|
nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
|
|
// …carrying NO reference back to the node that just went away.
|
|
nm.GetNodeReferences("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw)
|
|
.Any(r => r.TargetId == unsVar.NodeId).ShouldBeFalse();
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
|
|
[Fact]
|
|
public async Task RebuildAddressSpace_clears_all_registered_variables()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
|
|
sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
server.NodeManager!.VariableCount.ShouldBe(3);
|
|
|
|
sink.RebuildAddressSpace();
|
|
server.NodeManager.VariableCount.ShouldBe(0);
|
|
|
|
// After rebuild, subsequent writes start fresh.
|
|
sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
server.NodeManager.VariableCount.ShouldBe(1);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>Verifies that NullOpcUaAddressSpaceSink does not crash on any call.</summary>
|
|
[Fact]
|
|
public async Task NullOpcUaAddressSpaceSink_does_not_crash_on_any_call()
|
|
{
|
|
// Sanity check that the F10 fallback still works — production callers default to
|
|
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
|
var sink = NullOpcUaAddressSpaceSink.Instance;
|
|
sink.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.RebuildAddressSpace();
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it,
|
|
/// then projects active state through WriteAlarmCondition. Asserts the node is a real
|
|
/// <see cref="AlarmConditionState"/>, reachable under the equipment folder, and that
|
|
/// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children
|
|
/// <c>Create</c> auto-builds (the T13 uncertainty) and records the finding inline.</summary>
|
|
[Fact]
|
|
public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmCondition_updates_it()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
|
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);
|
|
|
|
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
|
|
nm.AlarmConditionCount.ShouldBe(1);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-1");
|
|
condition.ShouldNotBeNull();
|
|
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
|
condition.ShouldBeOfType<OffNormalAlarmState>();
|
|
// v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
|
|
// UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
|
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
|
condition.NodeId.ShouldBe(new NodeId("alm-1", unsNs));
|
|
|
|
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
|
|
condition.Parent.ShouldNotBeNull();
|
|
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs));
|
|
|
|
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
|
condition.EnabledState.Id.Value.ShouldBeTrue();
|
|
condition.ActiveState.Id.Value.ShouldBeFalse();
|
|
condition.Retain.Value.ShouldBeFalse();
|
|
|
|
// --- T13 optional-children finding (RESOLVED by THIS real-server test) ---
|
|
// AckedState is mandatory on AcknowledgeableConditionState so it is always present.
|
|
condition.AckedState.ShouldNotBeNull();
|
|
// FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the
|
|
// embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm
|
|
// sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is
|
|
// RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to
|
|
// instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState /
|
|
// SetShelvingState directly — no manual child materialisation needed. Asserting both non-null
|
|
// so a future SDK bump that changes auto-build behaviour fails loudly.
|
|
condition.ConfirmedState.ShouldNotBeNull();
|
|
condition.ShelvingState.ShouldNotBeNull();
|
|
|
|
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
|
|
// BaseDataVariable is minted for the alarm id.
|
|
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
|
|
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
|
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
|
|
|
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
nm.AlarmConditionCount.ShouldBe(1);
|
|
|
|
// RebuildAddressSpace clears the alarm dict too.
|
|
sink.RebuildAddressSpace();
|
|
nm.AlarmConditionCount.ShouldBe(0);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to
|
|
/// the base <see cref="AlarmConditionState"/> per the T13 notes.</summary>
|
|
[Fact]
|
|
public async Task MaterialiseAlarmCondition_unknown_type_falls_back_to_base_condition()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-x");
|
|
condition.ShouldNotBeNull();
|
|
// Base type exactly — NOT a LimitAlarmState (no limits to populate for a script alarm).
|
|
condition.GetType().ShouldBe(typeof(AlarmConditionState));
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T15 — the full condition state bridges through WriteAlarmCondition. Materialise a
|
|
/// condition, then push a rich snapshot (active + unacked + disabled + timed-shelved + high severity
|
|
/// + message) and assert every projected child reflects it:
|
|
/// ActiveState/AckedState/EnabledState/ConfirmedState/ShelvingState/Severity/Message/Retain.</summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_projects_full_state_onto_materialised_condition()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-rich");
|
|
condition.ShouldNotBeNull();
|
|
|
|
// Rich snapshot: active, unacknowledged, unconfirmed, DISABLED, TIMED-shelved, severity 850, message.
|
|
sink.WriteAlarmCondition(
|
|
"alm-rich",
|
|
new AlarmConditionSnapshot(
|
|
Active: true,
|
|
Acknowledged: false,
|
|
Confirmed: false,
|
|
Enabled: false,
|
|
Shelving: AlarmShelvingKind.Timed,
|
|
Severity: 850,
|
|
Message: "Pressure above limit"),
|
|
DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
|
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
|
condition.EnabledState.Id.Value.ShouldBeFalse(); // disabled
|
|
condition.ConfirmedState!.Id.Value.ShouldBeFalse(); // unconfirmed
|
|
// SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a
|
|
// shelved state; CurrentState is populated (TimedShelved).
|
|
condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull();
|
|
// Severity 850 is carried verbatim — not re-bucketed onto EventSeverity.High (900).
|
|
condition.Severity.Value.ShouldBe((ushort)850);
|
|
condition.Message.Value.Text.ShouldBe("Pressure above limit");
|
|
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T15 null-guard — a base <see cref="AlarmConditionState"/> (AlarmType "AlarmCondition")
|
|
/// whose optional ConfirmedState child the SDK might not materialise must not throw when a snapshot
|
|
/// sets Confirmed/Shelving. We force the ConfirmedState child to null after materialise to simulate a
|
|
/// leaner child set, then write a snapshot that sets Confirmed=true — the write must be a safe no-op
|
|
/// on that child rather than an NRE, while still projecting the mandatory state.</summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_null_optional_child_does_not_throw()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-base");
|
|
condition.ShouldNotBeNull();
|
|
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base type
|
|
|
|
// Simulate a build where the optional Confirm sub-state machine was NOT created.
|
|
condition.ConfirmedState = null;
|
|
|
|
// Setting Confirmed on a condition with no ConfirmedState child must not throw.
|
|
Should.NotThrow(() =>
|
|
sink.WriteAlarmCondition(
|
|
"alm-base",
|
|
new AlarmConditionSnapshot(
|
|
Active: true,
|
|
Acknowledged: false,
|
|
Confirmed: true, // targets the (now-null) optional child
|
|
Enabled: true,
|
|
Shelving: AlarmShelvingKind.OneShot,
|
|
Severity: 500,
|
|
Message: "still works"),
|
|
DateTime.UtcNow, AddressSpaceRealm.Uns));
|
|
|
|
// Mandatory state still projected despite the missing optional child.
|
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
|
condition.Message.Value.Text.ShouldBe("still works");
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T16 — each engine-driven <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/> on a
|
|
/// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event
|
|
/// <c>EventId</c> (GUID bytes). Part 9 requires a unique EventId per event so inbound
|
|
/// Acknowledge/Confirm (T17) can correlate back to the exact event being acked.
|
|
/// <para>
|
|
/// We assert EventId-freshness (non-null + changed from its materialise-time value, and a second
|
|
/// write yields a DIFFERENT EventId) plus no-throw. The node manager exposes no in-process hook to
|
|
/// observe <c>ReportEvent</c> delivery without standing up a full client subscription + monitored
|
|
/// item, so actual event DELIVERY to a subscribing client is proven live in T19 (Client.CLI) rather
|
|
/// than faked here — see the comment below.
|
|
/// </para></summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_fires_event_with_fresh_EventId_per_transition()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-evt");
|
|
condition.ShouldNotBeNull();
|
|
|
|
// EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[]
|
|
// reference is reused, so we compare snapshots, not the same array instance.
|
|
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
|
|
|
// First engine-driven transition → fires an event with a fresh EventId.
|
|
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
|
|
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
|
firstEventId.ShouldNotBeNull();
|
|
firstEventId!.Length.ShouldBe(16); // GUID bytes
|
|
// Changed from the materialise-time value (a real event fired, not the create-time stamp).
|
|
if (materialiseEventId is not null)
|
|
{
|
|
firstEventId.ShouldNotBe(materialiseEventId);
|
|
}
|
|
|
|
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
|
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
|
|
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
|
secondEventId.ShouldNotBeNull();
|
|
secondEventId!.ShouldNotBe(firstEventId);
|
|
|
|
// NOTE: event DELIVERY (a subscribing client actually receiving these ActiveState/AckedState
|
|
// transitions on the equipment folder) is an integration concern proven LIVE in T19 via the
|
|
// Client.CLI alarm subscription — not asserted here, and deliberately NOT faked. This unit-level
|
|
// test proves the firing path is exercised (no throw) and the Part 9 EventId-freshness invariant
|
|
// that T17's ack routing depends on.
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T16 — firing an event must not break the state projection even when ReportEvent could
|
|
/// fail. We can't easily force ReportEvent to throw in-process, but we CAN prove the projection +
|
|
/// firing path is exception-safe end-to-end across repeated transitions and that the condition's
|
|
/// mandatory state stays correct after firing.</summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_firing_does_not_disturb_projected_state()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
|
condition.ShouldNotBeNull();
|
|
|
|
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
|
Should.NotThrow(() =>
|
|
{
|
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
});
|
|
|
|
// Final projected state is intact after the last firing.
|
|
condition!.ActiveState.Id.Value.ShouldBeFalse();
|
|
condition.AckedState.Id.Value.ShouldBeTrue();
|
|
condition.Message.Value.Text.ShouldBe("cleared");
|
|
condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T20 — the inbound double-emit is suppressed by the delta-gate. We simulate the REAL
|
|
/// inbound sequence: a client Acknowledge whose T18 gate returned Good causes the SDK to apply the
|
|
/// acked state to the node and auto-fire its OWN event (E2) WITHOUT going through WriteAlarmCondition.
|
|
/// We reproduce that by applying the acked state directly onto the live AlarmConditionState the way
|
|
/// the SDK would. THEN the engine re-projects the same logical transition through WriteAlarmCondition
|
|
/// with the matching snapshot — and because that snapshot equals the node's current state, the
|
|
/// delta-gate must fire NO event (no E3). We prove "no event fired" by asserting the condition's
|
|
/// EventId is UNCHANGED across the WriteAlarmCondition call (ReportConditionEvent always restamps a
|
|
/// fresh GUID EventId when it fires — same probe the T16 event test uses).</summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_suppresses_event_when_snapshot_equals_node_current_state()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-ack");
|
|
condition.ShouldNotBeNull();
|
|
|
|
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
|
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
|
|
|
// === Simulate the SDK-applied inbound Acknowledge (E2) ===
|
|
// After T18's gate returns Good, the SDK applies the acked state to the node and auto-fires its
|
|
// own event — directly on the node, BYPASSING WriteAlarmCondition. Reproduce that node mutation.
|
|
lock (nm.Lock)
|
|
{
|
|
condition.SetAcknowledgedState(nm.SystemContext, true);
|
|
condition.Message.Value = new LocalizedText("active");
|
|
condition.ClearChangeMasks(nm.SystemContext, includeChildren: true);
|
|
}
|
|
condition.AckedState.Id.Value.ShouldBeTrue();
|
|
|
|
// Capture the EventId AFTER the SDK-applied ack but BEFORE the engine re-projection.
|
|
var beforeReProject = (byte[]?)condition.EventId.Value?.Clone();
|
|
|
|
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
|
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
|
// no change ⇒ NO event fires.
|
|
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
|
|
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
|
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
|
afterReProject.ShouldBe(beforeReProject);
|
|
|
|
// The projection still applied (state is intact) — only the redundant event was suppressed.
|
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
|
condition.AckedState.Id.Value.ShouldBeTrue();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T20 — genuine engine-driven transitions still fire exactly one event, and a second
|
|
/// IDENTICAL WriteAlarmCondition (no delta vs the node's now-current state) fires zero more. We use
|
|
/// the EventId-changed-on-fire probe: a fire restamps a fresh GUID EventId, a suppress leaves it
|
|
/// untouched.</summary>
|
|
[Fact]
|
|
public async Task WriteAlarmCondition_fires_on_delta_and_suppresses_identical_reprojection()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
|
|
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
|
|
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-delta");
|
|
condition.ShouldNotBeNull();
|
|
|
|
var beforeFirst = (byte[]?)condition!.EventId.Value?.Clone();
|
|
|
|
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
|
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
|
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
|
afterFirst.ShouldNotBeNull();
|
|
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
|
|
|
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
|
// events (EventId unchanged from the first fire).
|
|
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
|
afterSecond.ShouldBe(afterFirst); // suppressed
|
|
|
|
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
|
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
|
afterThird.ShouldNotBe(afterSecond); // fired
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>T20 — direct unit test of the pure fire-vs-suppress decision seam
|
|
/// (<see cref="OtOpcUaNodeManager.ShouldFireConditionEvent"/>). Equal states ⇒ suppress; any single
|
|
/// field differing ⇒ fire. This pins the gate logic independent of the booted server.</summary>
|
|
[Fact]
|
|
public void ShouldFireConditionEvent_fires_only_on_a_field_delta()
|
|
{
|
|
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
|
|
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m",
|
|
Quality: StatusCodes.Good);
|
|
|
|
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { }).ShouldBeFalse();
|
|
|
|
// Each single-field difference ⇒ fire.
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Active = false }).ShouldBeTrue();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Acknowledged = true }).ShouldBeTrue();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Confirmed = false }).ShouldBeTrue();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Enabled = false }).ShouldBeTrue();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
|
|
// #477 — a quality-only change (e.g. source going comms-lost, no state change) is a genuine delta.
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Quality = StatusCodes.Bad }).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
|
|
/// whose Message.Value.Text = null normalize to <see cref="string.Empty"/> in
|
|
/// <see cref="OtOpcUaNodeManager.AlarmConditionDelta"/>, so a snapshot whose Message is null does NOT
|
|
/// produce a phantom delta against a freshly-materialised node (whose Message is the displayName but
|
|
/// whose Text reads back as the displayName string, and a snapshot that explicitly passes an empty
|
|
/// string equally produces no delta vs a null-Message node). This pins the normalization so a
|
|
/// future change to the null-coalescing in ReadConditionDelta / ToConditionDelta is caught here.</summary>
|
|
[Fact]
|
|
public void ShouldFireConditionEvent_null_and_empty_message_normalize_identically()
|
|
{
|
|
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
|
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
|
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty,
|
|
Quality: StatusCodes.Good);
|
|
|
|
var withEmpty = withNull with { Message = string.Empty };
|
|
|
|
// null-message snapshot (normalised to "") vs empty-message node (normalised to "") ⇒ no delta.
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withEmpty).ShouldBeFalse();
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(withEmpty, withNull).ShouldBeFalse();
|
|
|
|
// A non-empty message IS a delta vs the empty baseline.
|
|
OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withNull with { Message = "pressure high" }).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
|
|
/// site only specifies the fields it cares about.</summary>
|
|
private static AlarmConditionSnapshot Snapshot(
|
|
bool active = false,
|
|
bool acknowledged = true,
|
|
bool confirmed = true,
|
|
bool enabled = true,
|
|
AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
|
|
ushort severity = 500,
|
|
string message = "test") =>
|
|
new(active, acknowledged, confirmed, enabled, shelving, severity, message);
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
|
{
|
|
var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.SinkTest",
|
|
ApplicationUri = $"urn:OtOpcUa.SinkTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var server = new OtOpcUaSdkServer();
|
|
await host.StartAsync(server, Ct);
|
|
return (host, server);
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|