From 45fa198494de969e3a61053fda72fb9704925af8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 04:51:14 -0400 Subject: [PATCH] feat(opcua): add EquipmentVirtualTagPlan to Phase7 composition Adds the EquipmentVirtualTagPlan sealed record (VirtualTagId, EquipmentId, FolderPath, Name, DataType, Expression, DependencyRefs) and the EquipmentVirtualTags init-only member on Phase7CompositionResult, mirroring the existing EquipmentTagPlan / EquipmentTags pattern. Type-only: no producer logic yet. Two new tests cover the default-empty guarantee and the record shape. --- .../Phase7Composer.cs | 24 +++++++++++++++++++ .../Phase7ComposerPurityTests.cs | 20 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 8ea28f82..8a5f00c1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -56,6 +56,10 @@ public sealed record Phase7CompositionResult( /// constructor + call site keeps compiling unchanged; new producers set it via initializer. /// public IReadOnlyList EquipmentTags { get; init; } = Array.Empty(); + + /// Equipment-namespace VirtualTags. See . Init-only, + /// defaults empty so every existing constructor + call site keeps compiling. + public IReadOnlyList EquipmentVirtualTags { get; init; } = Array.Empty(); } public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); @@ -100,6 +104,26 @@ public sealed record EquipmentTagPlan( string DataType, string FullName); +/// +/// One Equipment-namespace VirtualTag from a row (joined to its +/// for the expression). The VirtualTag value analogue of +/// : Phase7Applier.MaterialiseEquipmentVirtualTags +/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId +/// (EquipmentId/Name, or EquipmentId/FolderPath/Name when a sub-folder is set), +/// and VirtualTagHostActor spawns a VirtualTagActor per plan that evaluates +/// over and publishes the value back to +/// that NodeId. = the distinct ctx.GetTag("…") literals in +/// the script source. +/// +public sealed record EquipmentVirtualTagPlan( + string VirtualTagId, + string EquipmentId, + string FolderPath, + string Name, + string DataType, + string Expression, + IReadOnlyList DependencyRefs); + /// /// Pure composer that flattens the live-edit DB tables into the address-space build plan a /// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs index e78a23bc..a0f8295c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs @@ -91,6 +91,26 @@ public sealed class Phase7ComposerPurityTests node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode } + [Fact] + public void Composition_carries_empty_equipment_virtualtags_by_default() + { + var r = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()); + r.EquipmentVirtualTags.ShouldBeEmpty(); + } + + [Fact] + public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps() + { + var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64", + "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;", + new[] { "TestMachine_001.TestDouble" }); + p.VirtualTagId.ShouldBe("vt-1"); + p.EquipmentId.ShouldBe("eq-1"); + p.Name.ShouldBe("speed-rpm"); + p.DependencyRefs.ShouldHaveSingleItem(); + } + private static Equipment NewEquipment(string id) => new() { EquipmentId = id,