From a4b36c54ba5fcd381cb87124e49f2d0fe31f8690 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 07:49:28 -0400 Subject: [PATCH] feat(opcuaserver): Phase7Composer substitutes {{equip}} per equipment --- .../Phase7Composer.cs | 44 ++++---- .../Phase7ComposerEquipTokenTests.cs | 101 ++++++++++++++++++ 2 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index fe9cfb1c..212b26f8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -288,9 +289,22 @@ public static class Phase7Composer FullName: ExtractTagFullName(t.TagConfig))) .ToList(); + // Per-equipment tag base = the shared substring-before-first-dot across each equipment's + // child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag + // scripts (equipment-relative tag paths). Derived from equipmentTags so one script reused + // across N identical machines resolves to N machine-specific dependency graphs. + var baseByEquip = equipmentTags + .GroupBy(t => t.EquipmentId, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)), + StringComparer.Ordinal); + // Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the - // expression source. DependencyRefs = the distinct ctx.GetTag("…") literals the - // VirtualTagActor subscribes to. VirtualTag has no FolderPath today → "". + // expression source. The {{equip}} token is substituted with the owning equipment's tag + // base BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific. + // DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to. + // VirtualTag has no FolderPath today → "". var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal); var equipmentVirtualTags = vtags .OrderBy(v => v.EquipmentId, StringComparer.Ordinal) @@ -298,14 +312,16 @@ public static class Phase7Composer .Select(v => { var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty; + var expanded = EquipmentScriptPaths.SubstituteEquipmentToken( + src, baseByEquip.GetValueOrDefault(v.EquipmentId)); return new EquipmentVirtualTagPlan( VirtualTagId: v.VirtualTagId, EquipmentId: v.EquipmentId, FolderPath: string.Empty, Name: v.Name, DataType: v.DataType, - Expression: src, - DependencyRefs: ExtractDependencyRefs(src)); + Expression: expanded, + DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded)); }) .ToList(); @@ -342,24 +358,4 @@ public static class Phase7Composer catch (JsonException) { /* fall through to raw blob */ } return tagConfig; } - - private static readonly System.Text.RegularExpressions.Regex GetTagRefRegex = - new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", System.Text.RegularExpressions.RegexOptions.Compiled); - - /// Distinct ctx.GetTag("ref") string literals in a VirtualTag script source, - /// in first-seen order — the dependency refs the VirtualTagActor subscribes to. - /// The VirtualTag's script source. - /// The distinct dependency refs in first-seen order. - private static IReadOnlyList ExtractDependencyRefs(string scriptSource) - { - if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty(); - var seen = new HashSet(StringComparer.Ordinal); - var result = new List(); - foreach (System.Text.RegularExpressions.Match m in GetTagRefRegex.Matches(scriptSource)) - { - var r = m.Groups[1].Value; - if (seen.Add(r)) result.Add(r); - } - return result; - } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs new file mode 100644 index 00000000..215d4ebe --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs @@ -0,0 +1,101 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Verifies the live-edit compose seam () substitutes the +/// reserved {{equip}} token in a shared VirtualTag script with each owning equipment's +/// derived tag base (from its child-tag FullNames) — so one script reused across N +/// identical machines resolves to N machine-specific dependency graphs. +/// +public sealed class Phase7ComposerEquipTokenTests +{ + /// One shared using ctx.GetTag("{{equip}}.Source"), bound + /// to two equipments (TestMachine_001 / _002) each with one equipment Tag whose FullName carries + /// the per-machine base. Compose must expand the token per equipment in both the Expression and + /// the parsed DependencyRefs. + [Fact] + public void Compose_substitutes_equip_token_per_equipment() + { + var ns = new Namespace + { + NamespaceId = "ns-eq", + ClusterId = "c1", + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:eq", + }; + var driver1 = new DriverInstance + { + DriverInstanceId = "drv-1", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Modbus1", + DriverType = "Modbus", + DriverConfig = "{}", + }; + var driver2 = new DriverInstance + { + DriverInstanceId = "drv-2", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Modbus2", + DriverType = "Modbus", + DriverConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip1 = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" }; + var equip2 = new Equipment { EquipmentId = "eq-2", DriverInstanceId = "drv-2", UnsLineId = "line-1", Name = "TestMachine_002", MachineCode = "TESTMACHINE_002" }; + var tag1 = new Tag + { + TagId = "tag-1", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + Name = "Source", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_001.Source\",\"DataType\":\"Int32\"}", + }; + var tag2 = new Tag + { + TagId = "tag-2", + DriverInstanceId = "drv-2", + EquipmentId = "eq-2", + Name = "Source", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_002.Source\",\"DataType\":\"Int32\"}", + }; + var script = new Script + { + ScriptId = "s-equip", + Name = "over-50", + SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;", + SourceHash = "hash-equip", + }; + var vt1 = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" }; + var vt2 = new VirtualTag { VirtualTagId = "vt-2", EquipmentId = "eq-2", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" }; + + var result = Phase7Composer.Compose( + new[] { area }, new[] { line }, new[] { equip1, equip2 }, + new[] { driver1, driver2 }, Array.Empty(), + new[] { tag1, tag2 }, new[] { ns }, + virtualTags: new[] { vt1, vt2 }, + scripts: new[] { script }); + + result.EquipmentVirtualTags.Count.ShouldBe(2); + + var plan1 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-1"); + plan1.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")"); + plan1.Expression.ShouldNotContain("{{equip}}"); + plan1.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" }); + + var plan2 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-2"); + plan2.Expression.ShouldContain("ctx.GetTag(\"TestMachine_002.Source\")"); + plan2.Expression.ShouldNotContain("{{equip}}"); + plan2.DependencyRefs.ShouldBe(new[] { "TestMachine_002.Source" }); + } +}