f742050ebd
Three code-review points on commit 004558c2 were correct behavior
that was under-documented, not bugs:
1. AlarmConditionDelta gains explicit paragraphs explaining why
CommentAdded is absent: it always originates from a client
AddComment call whose T18 OnAddComment handler returns Good →
SDK auto-fires the comment event (E2); the engine re-projection
carries no delta-field change, so the gate correctly suppresses
the duplicate. Force-firing would double-emit.
2. Same doc explains Retain is intentionally absent: Retain is a
pure function of Active/Acknowledged (both compared), so it
cannot flip without a real delta. Notes future risk if that
ever changes.
3. ReportConditionEvent Time/ReceiveTime comment corrected: the
projection was already applied by WriteAlarmCondition above
with identical values; the restamp is a locality repeat, not a
reorder guard.
Also adds one seam unit-test (103 total, was 102) pinning the
null-vs-empty Message normalization boundary so a change to the
?? string.Empty coalescing is caught at the seam level.
552 lines
28 KiB
C#
552 lines
28 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, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
|
|
|
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
|
|
|
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);
|
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
|
|
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
|
|
|
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, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
|
|
server.NodeManager!.VariableCount.ShouldBe(3);
|
|
|
|
sink.RebuildAddressSpace();
|
|
server.NodeManager.VariableCount.ShouldBe(0);
|
|
|
|
// After rebuild, subsequent writes start fresh.
|
|
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
|
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, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
|
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");
|
|
|
|
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
|
|
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>();
|
|
condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex));
|
|
|
|
// 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", nm.NamespaceIndex));
|
|
|
|
// 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);
|
|
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);
|
|
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");
|
|
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
|
|
|
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");
|
|
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
|
|
|
|
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);
|
|
|
|
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 → High bucket (>= 800) → EventSeverity.High (the SDK stamps the ushort value).
|
|
condition.Severity.Value.ShouldBe((ushort)EventSeverity.High);
|
|
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");
|
|
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
|
|
|
|
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));
|
|
|
|
// 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");
|
|
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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");
|
|
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
|
|
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);
|
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
|
});
|
|
|
|
// 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");
|
|
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
|
|
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);
|
|
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);
|
|
|
|
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");
|
|
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700);
|
|
|
|
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);
|
|
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);
|
|
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);
|
|
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");
|
|
|
|
// 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();
|
|
}
|
|
|
|
/// <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);
|
|
|
|
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 */ }
|
|
}
|
|
}
|
|
}
|