feat(scripted-alarms): materialise real Part 9 AlarmConditionState nodes (T14)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user