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; /// /// Issue #473 — every materialised Part 9 condition must carry the mandatory BaseEventType /// identity fields EventType / SourceNode / SourceName. The SDK does NOT synthesise /// them on this path: initialises from the type's embedded definition, /// which declares all three as mandatory children with NO default value, and the auto-filling /// /// overload (which would set SourceNode/SourceName from a source node) is only used for transient /// events. ReportEvent and InstanceStateSnapshot copy the children verbatim and synthesise /// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event. /// /// The contract locked in here (see docs/AlarmTracking.md): the condition node IS the source /// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so /// SourceNode self-references the condition's own NodeId and SourceName carries the /// same identifying id string (alarmNodeId: the RawPath for native, the ScriptedAlarmId for /// scripted), matching ConditionId. The leaf/display name stays on ConditionName, so /// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf. /// /// 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}"); /// 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. [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"); } /// 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. [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"); } /// EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type /// falls back to the base and its EventType must report that base type /// rather than a subtype the node does not have. [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); } /// 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. [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"); } /// A booted server + its node manager, disposed via await using so an assertion failure /// cannot leak a live server (and its bound port) into the rest of the test run. private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable { public OtOpcUaNodeManager Nm { get; } = nm; public ValueTask DisposeAsync() => host.DisposeAsync(); } private async Task BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.AlarmSourceFieldsTest", ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.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 */ } } } }