e08b6b0e69
MaterialiseAlarmCondition never assigned the mandatory Part 9 ConditionType classification fields, so every condition event — native and scripted — shipped ConditionClassId = NodeId.Null (i=0) and ConditionClassName = empty text. Same mechanism as #473: Create() builds the mandatory children from the type's embedded definition but leaves them unset, and nothing downstream synthesises them (ReportEvent / InstanceStateSnapshot copy children verbatim). An HMI bucketing alarms by condition class dropped every OtOpcUa alarm as unclassified. Report BaseConditionClassType — Part 9's "no condition class modelled" value. This is the honest report: we hold no classification at the materialise seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick), which would assert a classification we cannot back and would be actively wrong for a Galaxy alarm whose upstream category is Safety/Diagnostics — trading a detectable null for an undetectable lie. Real per-alarm classification needs the driver's AlarmCategory carried to this deploy-time seam (it lives only on the runtime AlarmEventArgs transition today) and is a separate feature. Guards, both observed RED against the pre-fix server: - NativeAlarmEventIdentityFieldDeliveryTests: wire-level, its own select clause (the #473 test's clause mirrors ScadaBridge's exactly and its indices are load-bearing, so it is left untouched). The class fields are declared on ConditionType, not BaseEventType, so they are selected against that type. - NodeManagerAlarmSourceFieldsTests: node-level, native (Raw) + scripted (Uns). Stacked on #473 (PR #474) — merge after it.
241 lines
12 KiB
C#
241 lines
12 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Issue #473 — every materialised Part 9 condition must carry the mandatory <c>BaseEventType</c>
|
|
/// identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. The SDK does NOT synthesise
|
|
/// them on this path: <see cref="NodeState.Create"/> initialises from the type's embedded definition,
|
|
/// which declares all three as mandatory children with NO default value, and the auto-filling
|
|
/// <see cref="BaseEventState.Initialize(ISystemContext, NodeState, EventSeverity, LocalizedText)"/>
|
|
/// overload (which would set SourceNode/SourceName from a source node) is only used for transient
|
|
/// events. <c>ReportEvent</c> and <c>InstanceStateSnapshot</c> copy the children verbatim and synthesise
|
|
/// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
|
|
/// <para>
|
|
/// The contract locked in here (see <c>docs/AlarmTracking.md</c>): the condition node IS the source
|
|
/// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
|
|
/// <c>SourceNode</c> self-references the condition's own NodeId and <c>SourceName</c> carries the
|
|
/// same identifying id string (<c>alarmNodeId</c>: the RawPath for native, the ScriptedAlarmId for
|
|
/// scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
|
|
/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
|
|
/// </para>
|
|
/// <para>
|
|
/// Issue #475 — the same mechanism leaves the mandatory <c>ConditionType</c> classification fields
|
|
/// <c>ConditionClassId</c> / <c>ConditionClassName</c> unset. The contract locked in here: a server that
|
|
/// does not model condition classes must still report <c>BaseConditionClassType</c> (Part 9's
|
|
/// "no class modelled" value) rather than null. See <c>docs/AlarmTracking.md</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
|
private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
|
|
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
|
|
/// remains on ConditionName so the short name is still available to clients.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
|
|
{
|
|
await using var host = await BootAsync();
|
|
var nm = host.Nm;
|
|
var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);
|
|
|
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
|
|
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
|
condition.ShouldNotBeNull();
|
|
|
|
// EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
|
|
// where-clause) can resolve the type.
|
|
condition.EventType.ShouldNotBeNull();
|
|
condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
|
|
|
// SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
|
|
// for an alarm-bearing raw tag). Equal to ConditionId.
|
|
condition.SourceNode.ShouldNotBeNull();
|
|
condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
|
|
|
|
// SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
|
|
condition.SourceName.ShouldNotBeNull();
|
|
condition.SourceName.Value.ShouldBe(RawAlarmPath);
|
|
|
|
// The leaf name is NOT lost — it stays on ConditionName.
|
|
condition.ConditionName!.Value.ShouldBe("HR200");
|
|
|
|
}
|
|
|
|
/// <summary>A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
|
|
/// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
|
|
/// materialise path, so neither may regress independently.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
|
|
{
|
|
await using var host = await BootAsync();
|
|
var nm = host.Nm;
|
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
|
|
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
|
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
|
|
|
var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
|
|
condition.ShouldNotBeNull();
|
|
|
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
|
condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
|
|
condition.SourceName!.Value.ShouldBe("tank-overflow");
|
|
// The human-readable name stays on ConditionName.
|
|
condition.ConditionName!.Value.ShouldBe("Tank Overflow");
|
|
|
|
}
|
|
|
|
/// <summary>EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
|
|
/// falls back to the base <see cref="AlarmConditionState"/> and its EventType must report that base type
|
|
/// rather than a subtype the node does not have.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
|
|
{
|
|
await using var host = await BootAsync();
|
|
var nm = host.Nm;
|
|
|
|
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
|
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
|
|
condition.ShouldNotBeNull();
|
|
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
|
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
|
|
|
}
|
|
|
|
/// <summary>The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
|
|
/// kind-swap path), so a redeploy can never leave a condition emitting null identity.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task Identity_fields_survive_a_kind_swap_rematerialise()
|
|
{
|
|
await using var host = await BootAsync();
|
|
var nm = host.Nm;
|
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
|
|
|
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
|
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
|
// Kind swap (scripted → native) drops the prior instance and re-creates it.
|
|
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
|
|
realm: AddressSpaceRealm.Uns, isNative: true);
|
|
|
|
var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
|
|
condition.ShouldNotBeNull();
|
|
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
|
condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
|
|
condition.SourceName!.Value.ShouldBe("alm-swap");
|
|
|
|
}
|
|
|
|
/// <summary>#475 — a NATIVE condition (Raw realm) carries the mandatory ConditionType classification fields.
|
|
/// We do not model condition classes, so Part 9's "no class modelled" value — BaseConditionClassType — is the
|
|
/// conformant answer; the point is that it is a resolvable class NodeId a client can bucket on rather than a
|
|
/// null that drops the alarm into an unclassified bin.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task Native_condition_carries_ConditionClassId_and_ConditionClassName()
|
|
{
|
|
await using var host = await BootAsync();
|
|
var nm = host.Nm;
|
|
|
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
|
realm: AddressSpaceRealm.Raw, isNative: true);
|
|
|
|
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
|
condition.ShouldNotBeNull();
|
|
|
|
condition.ConditionClassId.ShouldNotBeNull();
|
|
condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
|
|
|
condition.ConditionClassName.ShouldNotBeNull();
|
|
condition.ConditionClassName.Value.ShouldNotBeNull();
|
|
condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
|
|
}
|
|
|
|
/// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
|
|
/// share the materialise path, so neither may regress independently.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task Scripted_condition_carries_ConditionClassId_and_ConditionClassName()
|
|
{
|
|
await using var host = await BootAsync();
|
|
var nm = host.Nm;
|
|
|
|
nm.EnsureFolder("eq-4", parentNodeId: null, displayName: "Station 4", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("tank-dry", "eq-4", "Tank Dry", "OffNormalAlarm", 700,
|
|
realm: AddressSpaceRealm.Uns, isNative: false);
|
|
|
|
var condition = nm.TryGetAlarmCondition("tank-dry", AddressSpaceRealm.Uns);
|
|
condition.ShouldNotBeNull();
|
|
|
|
condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
|
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
|
|
}
|
|
|
|
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
|
|
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
|
|
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
|
|
{
|
|
public OtOpcUaNodeManager Nm { get; } = nm;
|
|
public ValueTask DisposeAsync() => host.DisposeAsync();
|
|
}
|
|
|
|
private async Task<BootedServer> BootAsync()
|
|
{
|
|
var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
|
|
ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var server = new OtOpcUaSdkServer();
|
|
await host.StartAsync(server, Ct);
|
|
return new BootedServer(host, server.NodeManager!);
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|
|
}
|