feat(scripted-alarms): richer AlarmConditionState bridge to the OPC UA node (T15)

This commit is contained in:
Joseph Doherty
2026-06-10 19:41:16 -04:00
parent b31d7cb03f
commit 4eb1d65e2b
16 changed files with 349 additions and 108 deletions
@@ -9,7 +9,7 @@ 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>WriteAlarmState</c>/<c>RebuildAddressSpace</c>, and verify the
/// 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
@@ -36,14 +36,15 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Verifies that WriteAlarmState creates a dedicated node distinct from value writes.</summary>
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
/// distinct from value writes.</summary>
[Fact]
public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmState("alarm-7", active: true, acknowledged: false, DateTime.UtcNow);
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);
@@ -60,7 +61,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmState("alarm-c", true, false, DateTime.UtcNow);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace();
@@ -81,18 +82,18 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmState("a", true, false, 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 WriteAlarmState. Asserts the node is a real
/// 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_WriteAlarmState_updates_it()
public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmCondition_updates_it()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
@@ -101,7 +102,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// 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 WriteAlarmState targets it.
// 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);
@@ -134,9 +135,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
condition.ConfirmedState.ShouldNotBeNull();
condition.ShelvingState.ShouldNotBeNull();
// WriteAlarmState now targets the real condition (not the bool[2] placeholder): no extra
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
// BaseDataVariable is minted for the alarm id.
sink.WriteAlarmState("alm-1", active: true, acknowledged: false, DateTime.UtcNow);
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();
@@ -174,6 +175,107 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
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>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(