fix(alarms): delta-gate WriteAlarmCondition to suppress inbound ack double-emit (T20)

Inbound client acks now route through the engine (T18/T19). On a successful
Acknowledge the T18 gate returns Good, so the SDK applies the acked state to the
AlarmConditionState node and auto-fires its own condition event (E2) -- directly
on the node, bypassing WriteAlarmCondition. The engine then re-projects that same
transition through WriteAlarmCondition, which fired again (E3): a double-emit.

Gate WriteAlarmCondition's ReportConditionEvent on a genuine delta computed
against the node's CURRENT live state (read before projecting the snapshot), not
a last-written cache (which would be stale, since the SDK-applied ack never went
through this method). For a re-projected ack the snapshot equals the node's
already-applied state -> no delta -> suppress E3. Genuine engine-driven
transitions still differ -> fire.

Compared fields (value-equality via AlarmConditionDelta record): Active, Acked,
Confirmed, Enabled, Shelving (mapped from the shelving state machine), Severity
(mapped through MapSeverity to match the bucket the node stores), Message.
Optional Confirmed/Shelving fold to the node read-back default when the child is
absent so they can't register a phantom delta.

Tests prove both: suppression of the simulated inbound ack re-projection
(EventId unchanged) and that genuine transitions fire while identical
re-projections suppress; plus a direct unit test of the ShouldFireConditionEvent
seam. 102/102 OpcUaServer.Tests green.
This commit is contained in:
Joseph Doherty
2026-06-11 06:26:48 -04:00
parent 4f7999eac2
commit 004558c241
2 changed files with 235 additions and 17 deletions
@@ -354,6 +354,127 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
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>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(