diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 7e3c64da..89e3643e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -172,7 +172,7 @@ public sealed class Phase7Applier foreach (var tag in composition.EquipmentTags) { if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; - var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath); + var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); if (!foldersCreated.Add(folderNodeId)) continue; SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath); } @@ -187,8 +187,8 @@ public sealed class Phase7Applier { var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.EquipmentId - : EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath); - var nodeId = $"{parent}/{tag.Name}"; + : EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); + var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType); } @@ -223,7 +223,7 @@ public sealed class Phase7Applier foreach (var v in composition.EquipmentVirtualTags) { if (string.IsNullOrWhiteSpace(v.FolderPath)) continue; - var folderNodeId = EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath); + var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); if (!foldersCreated.Add(folderNodeId)) continue; SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath); } @@ -234,8 +234,8 @@ public sealed class Phase7Applier { var parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId - : EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath); - var nodeId = $"{parent}/{v.Name}"; + : EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); + var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); SafeEnsureVariable(nodeId, parent, v.Name, v.DataType); } @@ -275,11 +275,6 @@ public sealed class Phase7Applier .Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count()); } - /// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment - /// folder so two equipments' identically-named sub-folders never collide. - private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) => - $"{equipmentId}/{folderPath}"; - private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName) { try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 1015b315..fb92f253 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -17,10 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value. /// /// -/// The published NodeId is computed with the identical formula -/// Phase7Applier.MaterialiseEquipmentVirtualTags uses to materialise the variable — -/// {parent}/{Name} where parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId : -/// {EquipmentId}/{FolderPath} — or the value would land on a NodeId that does not exist. +/// The published NodeId is computed by the shared — +/// the single source of truth Phase7Applier.MaterialiseEquipmentVirtualTags also +/// materialises against — so the value always lands on a NodeId that exists. /// /// public sealed class VirtualTagHostActor : ReceiveActor @@ -143,14 +142,9 @@ public sealed class VirtualTagHostActor : ReceiveActor } } - /// Folder-scoped NodeId for a VirtualTag plan — MUST match - /// Phase7Applier.MaterialiseEquipmentVirtualTags exactly, or the published value lands on a - /// NodeId that was never materialised. - private static string NodeIdFor(EquipmentVirtualTagPlan p) - { - var parent = string.IsNullOrWhiteSpace(p.FolderPath) - ? p.EquipmentId - : $"{p.EquipmentId}/{p.FolderPath}"; - return $"{parent}/{p.Name}"; - } + /// Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared + /// (the single source of truth that Phase7Applier also + /// materialises against), so the published value always lands on the NodeId that was materialised. + private static string NodeIdFor(EquipmentVirtualTagPlan p) => + EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name); } 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 6820cd42..9a2e5f36 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -139,6 +139,8 @@ public sealed class Phase7ApplierTests sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float")); + // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). + sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); } /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment @@ -163,6 +165,8 @@ public sealed class Phase7ApplierTests sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float")); + // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). + sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); } /// Regression for the FullName-as-NodeId collision: two identical machines exposing the @@ -215,6 +219,47 @@ public sealed class Phase7ApplierTests sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64")); + // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. + sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm")); + } + + /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and + /// the equipment-VirtualTag pass is byte-identical to — the + /// single source of truth Phase7Applier + VirtualTagHostActor both point at. Covers null/empty + /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test + /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the + /// shared helper fails here. + [Fact] + public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"), + new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"), + }, + EquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64", + Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), + new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64", + Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + applier.MaterialiseEquipmentVirtualTags(composition); + + var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); + nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); + nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency")); + nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg")); } /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables