feat(scripted-alarms): materialise real Part 9 AlarmConditionState nodes (T14)

This commit is contained in:
Joseph Doherty
2026-06-10 19:19:10 -04:00
parent 4217b213b0
commit 60d48a2a0a
14 changed files with 443 additions and 12 deletions
@@ -81,6 +81,9 @@ public sealed class DeferredAddressSpaceSinkTests
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc />
@@ -252,6 +252,13 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// <summary>Records a folder creation request.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
@@ -311,6 +311,38 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
}
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
/// disabled alarms.</summary>
[Fact]
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentScriptedAlarms = new[]
{
new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
},
};
applier.MaterialiseScriptedAlarms(composition);
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700));
}
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
@@ -419,6 +451,8 @@ public sealed class Phase7ApplierTests
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// <summary>Gets the queue of variable creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
@@ -428,6 +462,8 @@ public sealed class Phase7ApplierTests
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// <summary>Gets the list of recorded variable creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// <summary>Records a value write (no-op in this recording sink).</summary>
/// <param name="nodeId">The node ID.</param>
@@ -442,6 +478,14 @@ public sealed class Phase7ApplierTests
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
/// <summary>Records an alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
@@ -482,6 +526,13 @@ public sealed class Phase7ApplierTests
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// <summary>No-op alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
@@ -85,6 +86,94 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
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
/// <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()
{
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 WriteAlarmState 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();
// WriteAlarmState 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);
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();
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
@@ -201,6 +201,13 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
/// <summary>Materialises an alarm condition (stub implementation).</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="equipmentNodeId">The equipment folder node identifier.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// <summary>Ensures folder exists (stub implementation).</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
@@ -253,6 +253,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="ts">The timestamp of the state change.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
=> Calls.Enqueue($"WA:{alarmNodeId}");
/// <summary>Records a materialise-alarm-condition call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
=> Calls.Enqueue($"MA:{alarmNodeId}");
/// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
@@ -176,6 +176,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
/// <summary>Materialises an alarm condition (no-op in test).</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
/// <summary>Ensures a folder exists (no-op in test).</summary>
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>