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.
This commit is contained in:
Joseph Doherty
2026-06-11 06:38:31 -04:00
parent 1d7e2a0f8b
commit f742050ebd
2 changed files with 53 additions and 3 deletions
@@ -475,6 +475,31 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
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(