From 7339a4af07c3f0cdaa95eb2beeb130acc495c533 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 17 Jul 2026 00:36:44 -0400 Subject: [PATCH] fix(alarms): populate EventType/SourceNode/SourceName on conditions (#473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaterialiseAlarmCondition never assigned the three mandatory BaseEventType identity fields, so all three arrived null on every condition event — native and scripted. The SDK does not synthesise them on this path: Create() builds the children from the type definition but leaves them unset, the auto-filling BaseEventState.Initialize overload is only used for transient events, and ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming client could not attribute an alarm to its source. EventType = the concrete materialised type (TypeDefinitionId) SourceNode = the condition's own NodeId (== ConditionId) — the condition IS the source; an alarm-bearing raw tag materialises only the condition, with no sibling value variable SourceName = the same identifying id string: RawPath (native) / ScriptedAlarmId (scripted) SourceName carries the unique id rather than the leaf name: the leaf collides across devices (HR200 on two PLCs) and is already carried by ConditionName, so nothing is lost. Documented in docs/AlarmTracking.md, including that clients must key on ConditionId and must not compose SourceName with ConditionName, and that SourceName is NOT a live<->history join key (the alarm-history writer stamps it with the EquipmentPath — a pre-existing divergence, now called out). Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard — a real client subscription using the standard [EventType, SourceNode, SourceName, Time, Message, Severity] select clause, verified to fail against the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across both realms, the base-type fallback, and the kind-swap re-materialise. The HistoryRead events projection is a separate path (it projects historian rows, not node fields) and is unaffected — its EventType => Variant.Null assertions still hold. --- docs/AlarmTracking.md | 43 +++ .../OtOpcUaNodeManager.cs | 16 ++ ...iveAlarmEventIdentityFieldDeliveryTests.cs | 257 ++++++++++++++++++ .../NodeManagerAlarmSourceFieldsTests.cs | 188 +++++++++++++ 4 files changed, 504 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 9046cda7..a220a6b1 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -51,6 +51,49 @@ same condition is wired as an **event notifier of every referencing equipment fo equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary identity RawPath + condition NodeId) with the equipment list as display metadata. +## Condition event identity fields (what a client reads on the wire) + +Every condition event — native and scripted — carries the mandatory `BaseEventType` identity +fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK +does **not** synthesize them on this path (`Create` builds the children from the type definition +but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they +are set explicitly. Leaving them unset shipped them as **null** on every event — see issue #473. + +| Field | Value | Notes | +|---|---|---| +| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause | +| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at | +| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name | +| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives | + +**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on +two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no +identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId` +triple mutually consistent and unique. This diverges from the loose OPC UA convention that +`SourceName` mirrors the source node's BrowseName; the divergence is intentional. + +**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals +`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for +scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same +identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with +`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the +condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`). + +Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three fields arrive +populated on a real subscription using the standard `[EventType, SourceNode, SourceName, Time, +Message, Severity]` select clause; `NodeManagerAlarmSourceFieldsTests` guards the node itself +across both realms. + +> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.** +> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns +> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's +> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the +> alarm-history writer stamps that field with the **EquipmentPath** +> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries. +> So `SourceName` is the one field populated on both paths **with different values**, and it is not +> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the +> live-path fix above does not change the history path.) + ## Galaxy driver path (driver-native) Restored in PR B.2 of the epic. `GalaxyDriver` implements diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index e882ad1e..32c74758 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -799,6 +799,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // (Real-server finding from the T14 integration test — not obvious from the SDK notes.) if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null; + // #473 — the mandatory BaseEventType identity fields. Create() builds these three children from + // the type's embedded definition but leaves them UNSET (the init string declares them mandatory + // with NO default), and nothing downstream fills them in: the auto-filling + // BaseEventState.Initialize(context, source, severity, message) overload is only used for + // transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset + // here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm. + // SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An + // alarm-bearing raw tag materialises ONLY this condition, with no sibling value + // variable (see AddressSpaceApplier's alarm branch), so there is no other node. + // SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted). + // Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and + // is already carried by ConditionName below. See docs/AlarmTracking.md. + alarm.EventType.Value = alarm.TypeDefinitionId; + alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it + alarm.SourceName.Value = alarmNodeId; + // Initial state via the SDK setters (T14: basic state only, NO event firing). alarm.SetEnableState(SystemContext, true); alarm.SetActiveState(SystemContext, false); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs new file mode 100644 index 00000000..eb53a4f1 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs @@ -0,0 +1,257 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory +/// BaseEventType identity fields EventType / SourceNode / SourceName. All three +/// previously arrived null on every condition event: MaterialiseAlarmCondition never assigned +/// them, and nothing downstream synthesises them (ReportEvent / InstanceStateSnapshot copy the +/// condition's children verbatim), so a conforming client could not attribute an alarm to its source. +/// +/// This is the WIRE-level guard the node-level +/// NodeManagerAlarmSourceFieldsTests cannot give: it proves the values survive the snapshot + +/// serialization all the way into a real client's EventFieldList. The select clause is deliberately +/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects +/// [EventType, SourceNode, SourceName, Time, Message, Severity] — so this test fails the way that +/// client failed. +/// +/// Boots the real and drives the production +/// , mirroring NativeAlarmMultiNotifierEventDeliveryTests. Heavy +/// in-process server+client integration — runs in the serial integration pass. +/// +public sealed class NativeAlarmEventIdentityFieldDeliveryTests +{ + private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields"; + + private const string RawDeviceFolder = "pymodbus/plc"; + private const string RawAlarmPath = "pymodbus/plc/HR200"; + private const string LeafName = "HR200"; + private const string Equip1 = "EQ-filling-line1-station1"; + private const string AlarmMessage = "HR200 over limit (identity-field test)"; + + // Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause. + private const int EventTypeIndex = 0; + private const int SourceNodeIndex = 1; + private const int SourceNameIndex = 2; + private const int TimeIndex = 3; + private const int MessageIndex = 4; + private const int SeverityIndex = 5; + + /// A live native condition event delivers a populated EventType / SourceNode / SourceName to a + /// Server-object subscriber using the standard BaseEventType select clause. + [Fact] + public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + WireCondition(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + // Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the + // expected SourceNode is built exactly as a real client would resolve it. + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + rawNs.ShouldBeGreaterThan((ushort)0); + + var collector = new EventCollector(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + subscription.FastEventCallback = collector.OnEvents; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + + // The collector filters by our unique Message, so the item's ClientHandle is not needed here + // (unlike the sibling multi-notifier test, which tallies delivery per monitored item). + AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); + + await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5)); + var fields = collector.Fields.ShouldHaveSingleItem(); + + // --- the three fields this issue is about --- + // EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause). + fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType, + "EventType must carry the concrete condition type, not null"); + + // SourceNode: the condition's own NodeId in the RAW namespace == ConditionId. + fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs), + "SourceNode must identify the source condition node, not null"); + + // SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf + // name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName. + fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath, + "SourceName must carry the unique RawPath, not null"); + fields[SourceNameIndex].Value.ShouldNotBe(LeafName, + "SourceName is deliberately the unique RawPath, not the ambiguous leaf name"); + + // The rest of the standard envelope still arrives intact (guards against a regression that + // populated identity at the cost of the existing fields). + fields[TimeIndex].Value.ShouldBeOfType(); + ((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage); + fields[SeverityIndex].Value.ShouldBe((ushort)700); + + await subscription.DeleteAsync(true, ct); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Materialise the raw device folder + the native condition at its RawPath, plus one referencing + /// equipment folder wired as a notifier (the production topology). + private static void WireCondition(SdkAddressSpaceSink sink) + { + sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw); + sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); + sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); + sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns); + } + + private static AlarmConditionSnapshot ActiveSnapshot() => + new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage); + + private static MonitoredItem AddEventItem(Subscription subscription, NodeId source) + { + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = source, + AttributeId = Attributes.EventNotifier, + Filter = BuildEventFilter(), + QueueSize = 100, + SamplingInterval = 0, + }; + subscription.AddItem(item); + return item; + } + + /// ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity]. + private static EventFilter BuildEventFilter() + { + var filter = new EventFilter(); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity); + return filter; + } + + /// Captures the delivered event field lists, filtered to our unique alarm Message so unrelated + /// server events / refresh brackets never count. + private sealed class EventCollector + { + private readonly object _gate = new(); + private readonly List _fields = new(); + + public IReadOnlyList Fields + { + get { lock (_gate) return _fields.ToList(); } + } + + public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) + { + lock (_gate) + { + foreach (var e in notification.Events) + { + if (e.EventFields.Count > MessageIndex && + e.EventFields[MessageIndex].Value is LocalizedText lt && + lt.Text == AlarmMessage) + { + _fields.Add(e.EventFields); + } + } + } + } + } + + private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( + int port, string pkiRoot, string appUri, CancellationToken ct) + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = appUri, + ApplicationUri = appUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + }; + var server = new OtOpcUaSdkServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, ct); + return (server, host); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient", + ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, + sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue("condition not met within timeout"); + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static void SafeDelete(string dir) + { + if (Directory.Exists(dir)) + { + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs new file mode 100644 index 00000000..2156a337 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs @@ -0,0 +1,188 @@ +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 */ } + } + } +}