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 33ec3a59..07137975 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; @@ -193,7 +194,7 @@ public static class DeploymentArtifact var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); var galaxyTags = BuildGalaxyTagPlans(root, drivers); var equipmentTags = BuildEquipmentTagPlans(root); - var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root); + var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags); return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags) { @@ -525,16 +526,31 @@ public static class DeploymentArtifact /// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one /// per VirtualTag. The artifact-decode mirror of /// Phase7Composer.Compose's VirtualTag producer — so the compose-side + artifact-decode - /// plans agree. Expression = the joined Script's SourceCode (empty when the - /// ScriptId is absent); DependencyRefs = the distinct ctx.GetTag("…") literals in - /// that source; FolderPath is always "" (VirtualTag has no FolderPath today). Ordered by - /// EquipmentId then Name to match the composer's deterministic ordering. + /// plans agree. The reserved {{equip}} token in the joined Script's SourceCode is + /// substituted with the owning equipment's tag base (derived from ' + /// FullNames) BEFORE refs are extracted, byte-parity with the composer. Expression = the + /// substituted source (empty when the ScriptId is absent); DependencyRefs = the distinct + /// ctx.GetTag("…") literals in that source; FolderPath is always "" (VirtualTag has + /// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic + /// ordering. /// - private static IReadOnlyList BuildEquipmentVirtualTagPlans(JsonElement root) + private static IReadOnlyList BuildEquipmentVirtualTagPlans( + JsonElement root, IReadOnlyList equipmentTags) { if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array) return Array.Empty(); + // 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 the artifact-decode + // base matches the composer's exactly. + var baseByEquip = equipmentTags + .GroupBy(t => t.EquipmentId, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)), + StringComparer.Ordinal); + // scriptId → SourceCode (the expression source the VirtualTagActor evaluates). var scriptSourceById = new Dictionary(StringComparer.Ordinal); if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array) @@ -566,14 +582,20 @@ public static class DeploymentArtifact var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : string.Empty; + // Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting + // refs, so both Expression and DependencyRefs are machine-specific — byte-parity with + // Phase7Composer.Compose. + var expanded = EquipmentScriptPaths.SubstituteEquipmentToken( + source, baseByEquip.GetValueOrDefault(equipmentId!)); + result.Add(new EquipmentVirtualTagPlan( VirtualTagId: virtualTagId!, EquipmentId: equipmentId!, FolderPath: string.Empty, Name: name!, DataType: dataType ?? "BaseDataType", - Expression: source, - DependencyRefs: ExtractDependencyRefs(source))); + Expression: expanded, + DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded))); } result.Sort((a, b) => @@ -584,28 +606,6 @@ public static class DeploymentArtifact return result; } - 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 artifact-decode mirror of Phase7Composer.ExtractDependencyRefs - /// — replicated (with the same regex) because Runtime does not reference the OpcUaServer - /// compose assembly; kept in sync with that copy. - /// - 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; - } - /// /// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName" /// field). The artifact-decode mirror of Phase7Composer.ExtractTagFullName / diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs new file mode 100644 index 00000000..3ea4c8c4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Verifies the artifact-decode mirror substitutes the reserved {{equip}} token in a +/// VirtualTag script's ctx.GetTag("…") literals with the owning equipment's tag base +/// (derived from its child Equipment-namespace tag's FullName) — byte-parity with +/// Phase7Composer.Compose's live-edit path, using the same shared +/// EquipmentScriptPaths helper and the same equipmentTags-derived base. +/// +public sealed class DeploymentArtifactEquipTokenTests +{ + [Fact] + public void ParseComposition_substitutes_equip_token_in_virtual_tag_expression() + { + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-source", + DriverInstanceId = "drv-modbus", + EquipmentId = "TestMachine_001", + Name = "Source", + FolderPath = (string?)null, + DataType = "Float", + TagConfig = "{\"FullName\":\"TestMachine_001.Source\"}", + }, + }, + Scripts = new[] + { + new + { + ScriptId = "s-equip", + SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;", + }, + }, + VirtualTags = new[] + { + new + { + VirtualTagId = "vt-equip", + EquipmentId = "TestMachine_001", + Name = "OverThreshold", + DataType = "Boolean", + ScriptId = "s-equip", + }, + }, + }); + + var c = DeploymentArtifact.ParseComposition(blob); + + var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem(); + vt.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")"); + vt.Expression.ShouldNotContain("{{equip}}"); + vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" }); + } +}