Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
T
Joseph Doherty f742050ebd docs(opcua): explain intentional CommentAdded/Retain delta-gate suppression (T20 review)
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.
2026-06-11 06:38:31 -04:00

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 */ }
}
}
}