diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index f79258f..5cd8384 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -27,5 +27,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index 76ac52f..7082ddf 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -14,6 +14,14 @@ public interface IOpcUaAddressSpaceSink /// Write an alarm-condition Variable's active/acknowledged state. void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc); + /// + /// Ensure a folder node exists under the given parent. Used by Phase7Applier to + /// materialise the UNS Area/Line/Equipment hierarchy in the address space. When + /// is null the folder is parented under the namespace + /// root. Idempotent: calling twice with the same id is safe. + /// + void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName); + /// /// Tear down + repopulate the address space. Called by OpcUaPublishActor after a /// successful deployment apply so the node manager reflects the new config. Idempotent. @@ -33,5 +41,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink private NullOpcUaAddressSpaceSink() { } public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } public void RebuildAddressSpace() { } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index b5ec9ea..b71af57 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -27,6 +27,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns"; private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal); private FolderState? _root; public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration) @@ -36,6 +37,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } public int VariableCount => _variables.Count; + public int FolderCount => _folders.Count; /// /// Apply a value write from . Creates the @@ -73,9 +75,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - /// Clear every registered variable from the address space. Phase7Applier calls this - /// when Equipment/Alarm topology changes; the populator then re-adds via WriteValue on the - /// next pass. + /// + /// Ensure a folder node exists at with the given display + /// name, parented under (or the namespace root when null). + /// #85 — used by to materialise the UNS Area/Line/Equipment + /// folder hierarchy. Idempotent: the second call with the same id returns the cached + /// folder so adding child variables under it still works. + /// + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + { + ArgumentException.ThrowIfNullOrEmpty(folderNodeId); + ArgumentException.ThrowIfNullOrEmpty(displayName); + + if (_folders.ContainsKey(folderNodeId)) return; + + lock (Lock) + { + if (_folders.ContainsKey(folderNodeId)) return; + + var parent = ResolveParentFolder(parentNodeId); + var folder = new FolderState(parent) + { + NodeId = new NodeId(folderNodeId, NamespaceIndex), + BrowseName = new QualifiedName(folderNodeId, NamespaceIndex), + DisplayName = displayName, + EventNotifier = EventNotifiers.None, + TypeDefinitionId = ObjectTypeIds.FolderType, + ReferenceTypeId = ReferenceTypeIds.Organizes, + }; + parent.AddChild(folder); + AddPredefinedNode(SystemContext, folder); + _folders[folderNodeId] = folder; + } + } + + /// Clear every registered variable + folder from the address space. Phase7Applier + /// calls this when Equipment/Alarm topology changes; the populator then re-adds via + /// EnsureFolder + WriteValue on the next pass. public void RebuildAddressSpace() { lock (Lock) @@ -86,9 +122,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 PredefinedNodes?.Remove(v.NodeId); } _variables.Clear(); + + foreach (var f in _folders.Values) + { + f.Parent?.RemoveChild(f); + PredefinedNodes?.Remove(f.NodeId); + } + _folders.Clear(); } } + private FolderState ResolveParentFolder(string? parentNodeId) + { + if (string.IsNullOrEmpty(parentNodeId)) return _root!; + return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!; + } + /// public override void CreateAddressSpace(IDictionary> externalReferences) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 35ad7d2..172a30d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -94,6 +94,43 @@ public sealed class Phase7Applier return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild); } + /// + /// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a + /// composition snapshot. Called by OpcUaPublishActor after a rebuild so OPC UA + /// clients browsing the server see proper folder structure instead of flat tag ids. + /// Idempotent: each EnsureFolder call returns the existing folder if already + /// present, so re-applies are cheap. + /// + public void MaterialiseHierarchy(Phase7CompositionResult composition) + { + ArgumentNullException.ThrowIfNull(composition); + + foreach (var area in composition.UnsAreas) + { + SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName); + } + foreach (var line in composition.UnsLines) + { + SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName); + } + foreach (var equipment in composition.EquipmentNodes) + { + // Equipment with no UnsLineId (legacy / dev rows) hang under the root. + var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId; + SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName); + } + + _logger.LogInformation( + "Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})", + composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count); + } + + private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName) + { + try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); } + catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); } + } + private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts) { try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 4a3301c..bb23222 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -2,12 +2,30 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; -/// Outcome of — pure value tuple, no side effects. +/// Outcome of — pure value tuple, no side effects. +/// + carry the UNS topology so the applier can +/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries +/// its parent line id so the applier knows where to hang each equipment folder. public sealed record Phase7CompositionResult( + IReadOnlyList UnsAreas, + IReadOnlyList UnsLines, IReadOnlyList EquipmentNodes, IReadOnlyList DriverInstancePlans, - IReadOnlyList ScriptedAlarmPlans); + IReadOnlyList ScriptedAlarmPlans) +{ + /// Convenience constructor for tests + earlier callers that don't yet carry UNS topology. + public Phase7CompositionResult( + IReadOnlyList equipmentNodes, + IReadOnlyList driverInstancePlans, + IReadOnlyList scriptedAlarmPlans) + : this(Array.Empty(), Array.Empty(), + equipmentNodes, driverInstancePlans, scriptedAlarmPlans) + { + } +} +public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); +public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId); public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson); public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate); @@ -17,18 +35,38 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI /// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role /// startup (Task 53) consumes the result and hands it to the node-manager factory. /// -/// Full migration of the legacy Server.Phase7.Phase7Composer (which mutates a server-side -/// node cache, emits trace logs, and calls into EquipmentNodeWalker) is tracked as -/// follow-up F14. This pure version handles the projection step; the side-effecting wiring -/// stays in the legacy code until F14 lands. +/// #85 — the composer now carries UNS topology ( + +/// ) so Phase7Applier can build the +/// Area/Line/Equipment folder hierarchy in the SDK's address space. The legacy +/// EquipmentNodeWalker integration that did this server-side is fully replaced by the +/// (composer → applier → sink → node manager) chain. /// public static class Phase7Composer { + /// Convenience overload for legacy callers + tests that don't yet supply UNS topology. public static Phase7CompositionResult Compose( + IReadOnlyList equipment, + IReadOnlyList driverInstances, + IReadOnlyList scriptedAlarms) => + Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms); + + public static Phase7CompositionResult Compose( + IReadOnlyList unsAreas, + IReadOnlyList unsLines, IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms) { + var areas = unsAreas + .OrderBy(a => a.UnsAreaId, StringComparer.Ordinal) + .Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name)) + .ToList(); + + var lines = unsLines + .OrderBy(l => l.UnsLineId, StringComparer.Ordinal) + .Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name)) + .ToList(); + var nodes = equipment .OrderBy(e => e.EquipmentId, StringComparer.Ordinal) .Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId)) @@ -44,6 +82,6 @@ public static class Phase7Composer .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) .ToList(); - return new Phase7CompositionResult(nodes, plans, alarms); + return new Phase7CompositionResult(areas, lines, nodes, plans, alarms); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index cd18d98..8395816 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -24,5 +24,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 8cfecd8..b2a8d12 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -89,34 +89,34 @@ public static class DeploymentArtifact /// public static Phase7CompositionResult ParseComposition(ReadOnlySpan blob) { - if (blob.IsEmpty) - { - return new Phase7CompositionResult( - Array.Empty(), - Array.Empty(), - Array.Empty()); - } + if (blob.IsEmpty) return Empty(); try { using var doc = JsonDocument.Parse(blob.ToArray()); var root = doc.RootElement; + var areas = ReadArray(root, "UnsAreas", ReadAreaProjection); + var lines = ReadArray(root, "UnsLines", ReadLineProjection); var equipment = ReadArray(root, "Equipment", ReadEquipmentNode); var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); - return new Phase7CompositionResult(equipment, drivers, alarms); + return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms); } catch (JsonException) { - return new Phase7CompositionResult( - Array.Empty(), - Array.Empty(), - Array.Empty()); + return Empty(); } } + private static Phase7CompositionResult Empty() => new( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader) where T : class { @@ -137,12 +137,31 @@ public static class DeploymentArtifact private static string IdentityOf(T item) where T : class => item switch { + UnsAreaProjection a => a.UnsAreaId, + UnsLineProjection l => l.UnsLineId, EquipmentNode e => e.EquipmentId, DriverInstancePlan d => d.DriverInstanceId, ScriptedAlarmPlan a => a.ScriptedAlarmId, _ => string.Empty, }; + private static UnsAreaProjection? ReadAreaProjection(JsonElement el) + { + var id = el.TryGetProperty("UnsAreaId", out var idEl) ? idEl.GetString() : null; + var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) return null; + return new UnsAreaProjection(id!, name ?? id!); + } + + private static UnsLineProjection? ReadLineProjection(JsonElement el) + { + var id = el.TryGetProperty("UnsLineId", out var idEl) ? idEl.GetString() : null; + var areaId = el.TryGetProperty("UnsAreaId", out var areaEl) ? areaEl.GetString() : null; + var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null; + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null; + return new UnsLineProjection(id!, areaId!, name ?? id!); + } + private static EquipmentNode? ReadEquipmentNode(JsonElement el) { var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 1ef9ae7..e2187e9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -185,6 +185,12 @@ public sealed class OpcUaPublishActor : ReceiveActor var outcome = _applier.Apply(plan); _lastApplied = composition; + + // #85 — after the plan diff lands, rebuild the UNS folder hierarchy so OPC UA + // clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier + // skips folders that already exist with the same node id. + _applier.MaterialiseHierarchy(composition); + OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild")); _log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})", msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs index 84b5996..fc9dee2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -72,6 +72,8 @@ public sealed class DeferredAddressSpaceSinkTests => CallQueue.Enqueue($"WV:{nodeId}"); public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => CallQueue.Enqueue($"WA:{alarmNodeId}"); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => CallQueue.Enqueue($"EF:{folderNodeId}"); public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs new file mode 100644 index 0000000..09c37d5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs @@ -0,0 +1,135 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// #85 — verifies builds the UNS +/// Area/Line/Equipment folder tree through . +/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test +/// drives a real and inspects the resulting predefined-node +/// count to prove the folders land in the SDK address space. +/// +public sealed class Phase7ApplierHierarchyTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine( + Path.GetTempPath(), + $"otopcua-pki-{Guid.NewGuid():N}"); + + [Fact] + public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents() + { + var sink = new RecordingFolderSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") }, + UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, + EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") }, + DriverInstancePlans: Array.Empty(), + ScriptedAlarmPlans: Array.Empty()); + + applier.MaterialiseHierarchy(composition); + + var calls = sink.Calls; + calls.Count.ShouldBe(3); + calls[0].ShouldBe(("area-1", null, "Plant North")); + calls[1].ShouldBe(("line-1", "area-1", "Cell A")); + calls[2].ShouldBe(("eq-1", "line-1", "Pump-1")); + } + + [Fact] + public void MaterialiseHierarchy_orphan_equipment_hangs_under_root() + { + var sink = new RecordingFolderSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + UnsAreas: Array.Empty(), + UnsLines: Array.Empty(), + EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") }, + DriverInstancePlans: Array.Empty(), + ScriptedAlarmPlans: Array.Empty()); + + applier.MaterialiseHierarchy(composition); + + sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan")); + } + + [Fact] + public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes() + { + await using var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Hierarchy", + ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance); + + var sdkServer = new OtOpcUaSdkServer(); + await host.StartAsync(sdkServer, Ct); + sdkServer.NodeManager.ShouldNotBeNull(); + + var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + applier.MaterialiseHierarchy(new Phase7CompositionResult( + UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") }, + UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, + EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, + DriverInstancePlans: Array.Empty(), + ScriptedAlarmPlans: Array.Empty())); + + sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment + + // Idempotent: re-applying with the same composition doesn't create duplicates. + applier.MaterialiseHierarchy(new Phase7CompositionResult( + UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") }, + UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, + EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, + DriverInstancePlans: Array.Empty(), + ScriptedAlarmPlans: Array.Empty())); + + sdkServer.NodeManager!.FolderCount.ShouldBe(5); + } + + private static int AllocateFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch { /* best-effort */ } + } + } + + private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink + { + private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new(); + public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList(); + + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => _calls.Enqueue((folderNodeId, parentNodeId, displayName)); + public void RebuildAddressSpace() { } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 9a85fd2..f866c4f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -125,13 +125,17 @@ public sealed class Phase7ApplierTests private sealed class RecordingSink : IOpcUaAddressSpaceSink { public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new(); + public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new(); public int RebuildCalls; public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList(); + public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList(); public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged)); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); } @@ -145,6 +149,7 @@ public sealed class Phase7ApplierTests { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } public void RebuildAddressSpace() { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index 462d609..13b5d88 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -172,6 +172,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase public int Writes { get; private set; } public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++; public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++; + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } public void RebuildAddressSpace() { /* recorded via span */ } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index b87b51e..9bc47e8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -139,6 +139,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase => Calls.Enqueue($"WV:{nodeId}"); public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) => Calls.Enqueue($"WA:{alarmNodeId}"); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => Calls.Enqueue($"EF:{folderNodeId}"); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index 006a5b1..5adcf39 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -152,6 +152,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts)); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); }