From f4315048250d84f29cb8940a5b14f3e1a33fa2a2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 07:42:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(commons):=20EquipmentScriptPaths=20?= =?UTF-8?q?=E2=80=94=20derive=20base=20+=20{{equip}}=20substitution=20+=20?= =?UTF-8?q?shared=20dep=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Types/EquipmentScriptPaths.cs | 95 +++++++++ .../EquipmentScriptPathsTests.cs | 187 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs new file mode 100644 index 00000000..24ed133b --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs @@ -0,0 +1,95 @@ +using System.Text.RegularExpressions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// Helpers for equipment-relative virtual-tag script paths. The reserved token +/// {{equip}} inside a ctx.GetTag/ctx.SetVirtualTag path literal is +/// replaced at the compose seams with the owning equipment's tag base prefix (derived +/// from its child-tag FullNames). Pure + regex-based (no Roslyn) so the OpcUaServer +/// composer and the Runtime artifact-decode path can both share it. Also the single home +/// for the ctx.GetTag("…") dependency-ref extraction those two seams used to +/// duplicate. +/// +public static class EquipmentScriptPaths +{ + /// The reserved equipment-base token. + public const string EquipToken = "{{equip}}"; + + // ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these. + private static readonly Regex GetTagRefRegex = + new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled); + + // ctx.GetTag("…") OR ctx.SetVirtualTag("…", …) — first string-literal arg captured in + // three parts (prefix, content, closing quote) so token substitution touches ONLY the + // literal content (never a comment, Logger string, or other code). + private static readonly Regex PathLiteralRegex = + new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled); + + /// True when the source uses the {{equip}} token anywhere. + /// The script source to scan. + public static bool ContainsEquipToken(string? source) => + !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal); + + /// + /// Equipment tag base = the single shared substring-before-first-dot across the + /// equipment's child-tag FullNames. Returns null when there are no usable + /// FullNames or they don't agree on one prefix (equipment spanning multiple objects). + /// + /// The equipment's child-tag driver FullNames. + /// The shared base prefix, or null when none/ambiguous. + public static string? DeriveEquipmentBase(IEnumerable childFullNames) + { + string? found = null; + foreach (var fn in childFullNames) + { + if (string.IsNullOrWhiteSpace(fn)) continue; + var dot = fn.IndexOf('.'); + var prefix = dot < 0 ? fn : fn.Substring(0, dot); + if (prefix.Length == 0) continue; + if (found is null) found = prefix; + else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null; + } + return found; + } + + /// + /// Replace {{equip}} with inside + /// ctx.GetTag/ctx.SetVirtualTag path literals only. Identity when + /// is null/empty or the token is absent (so every existing + /// script — none of which use the token — is byte-unchanged). + /// + /// The script source. + /// The equipment base prefix, or null/empty for no substitution. + /// The source with the token substituted inside path literals. + public static string SubstituteEquipmentToken(string source, string? equipBase) + { + if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source; + if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source; + return PathLiteralRegex.Replace(source, m => + m.Groups[1].Value + + m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal) + + m.Groups[3].Value); + } + + /// + /// Distinct ctx.GetTag("ref") string literals in first-seen order — the + /// dependency refs the VirtualTagActor subscribes to. The single shared copy + /// formerly duplicated in Phase7Composer + DeploymentArtifact. GetTag + /// only (writes are not dependencies). + /// + /// The (already substituted) script source. + /// Distinct read refs, first-seen order. + public static IReadOnlyList ExtractDependencyRefs(string? scriptSource) + { + if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (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/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs new file mode 100644 index 00000000..552c3f98 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs @@ -0,0 +1,187 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; + +public class EquipmentScriptPathsTests +{ + // ---- DeriveEquipmentBase ---- + + [Fact] + public void DeriveEquipmentBase_returns_shared_prefix() + { + EquipmentScriptPaths + .DeriveEquipmentBase(["TestMachine_001.A", "TestMachine_001.B"]) + .ShouldBe("TestMachine_001"); + } + + [Fact] + public void DeriveEquipmentBase_returns_null_when_prefixes_diverge() + { + EquipmentScriptPaths + .DeriveEquipmentBase(["TestMachine_001.A", "DelmiaReceiver_001.B"]) + .ShouldBeNull(); + } + + [Fact] + public void DeriveEquipmentBase_returns_null_for_empty_sequence() + { + EquipmentScriptPaths + .DeriveEquipmentBase([]) + .ShouldBeNull(); + } + + [Fact] + public void DeriveEquipmentBase_returns_whole_value_when_no_dot() + { + EquipmentScriptPaths + .DeriveEquipmentBase(["NoDot"]) + .ShouldBe("NoDot"); + } + + [Fact] + public void DeriveEquipmentBase_skips_null_empty_and_whitespace_entries() + { + EquipmentScriptPaths + .DeriveEquipmentBase([null, "", " ", "TestMachine_001.A"]) + .ShouldBe("TestMachine_001"); + } + + // ---- SubstituteEquipmentToken ---- + + [Fact] + public void SubstituteEquipmentToken_substitutes_inside_GetTag() + { + var result = EquipmentScriptPaths.SubstituteEquipmentToken( + "ctx.GetTag(\"{{equip}}.Source\")", "TestMachine_001"); + + result.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")"); + } + + [Fact] + public void SubstituteEquipmentToken_substitutes_inside_SetVirtualTag() + { + var result = EquipmentScriptPaths.SubstituteEquipmentToken( + "ctx.SetVirtualTag(\"{{equip}}.Out\", x)", "TestMachine_001"); + + result.ShouldContain("ctx.SetVirtualTag(\"TestMachine_001.Out\""); + } + + [Fact] + public void SubstituteEquipmentToken_leaves_comment_unchanged() + { + var source = "// uses {{equip}} here"; + + EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001") + .ShouldBe(source); + } + + [Fact] + public void SubstituteEquipmentToken_leaves_logger_string_unchanged() + { + var source = "ctx.Logger.Information(\"{{equip}}\")"; + + EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001") + .ShouldBe(source); + } + + [Fact] + public void SubstituteEquipmentToken_substitutes_all_occurrences() + { + var source = "ctx.GetTag(\"{{equip}}.A\"); ctx.GetTag(\"{{equip}}.B\");"; + + var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001"); + + result.ShouldContain("ctx.GetTag(\"TestMachine_001.A\")"); + result.ShouldContain("ctx.GetTag(\"TestMachine_001.B\")"); + result.ShouldNotContain(EquipmentScriptPaths.EquipToken); + } + + [Fact] + public void SubstituteEquipmentToken_is_identity_when_equipBase_is_null() + { + var source = "ctx.GetTag(\"{{equip}}.Source\")"; + + EquipmentScriptPaths.SubstituteEquipmentToken(source, null) + .ShouldBe(source); + } + + [Fact] + public void SubstituteEquipmentToken_is_identity_when_equipBase_is_empty() + { + var source = "ctx.GetTag(\"{{equip}}.Source\")"; + + EquipmentScriptPaths.SubstituteEquipmentToken(source, "") + .ShouldBe(source); + } + + [Fact] + public void SubstituteEquipmentToken_is_identity_when_token_absent() + { + var source = "ctx.GetTag(\"TestMachine_001.Source\")"; + + EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001") + .ShouldBe(source); + } + + [Fact] + public void SubstituteEquipmentToken_does_not_touch_raw_string_literal() + { + // Documents the regex limitation: only "double-quote" literals are handled, + // matching the existing seam extractors. A raw-string literal is left as-is. + var source = "ctx.GetTag(\"\"\"{{equip}}.X\"\"\")"; + + EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001") + .ShouldBe(source); + } + + // ---- ExtractDependencyRefs ---- + + [Fact] + public void ExtractDependencyRefs_returns_distinct_refs_in_first_seen_order() + { + var source = "ctx.GetTag(\"A\"); ctx.GetTag(\"B\"); ctx.GetTag(\"A\");"; + + EquipmentScriptPaths.ExtractDependencyRefs(source) + .ShouldBe(["A", "B"]); + } + + [Fact] + public void ExtractDependencyRefs_excludes_SetVirtualTag_writes() + { + var source = "ctx.GetTag(\"X\"); ctx.SetVirtualTag(\"Y\", 1);"; + + EquipmentScriptPaths.ExtractDependencyRefs(source) + .ShouldBe(["X"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExtractDependencyRefs_returns_empty_for_null_or_whitespace(string? source) + { + EquipmentScriptPaths.ExtractDependencyRefs(source).ShouldBeEmpty(); + } + + // ---- ContainsEquipToken ---- + + [Fact] + public void ContainsEquipToken_true_when_token_present() + { + EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}.X\")").ShouldBeTrue(); + } + + [Fact] + public void ContainsEquipToken_false_when_token_absent() + { + EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"X\")").ShouldBeFalse(); + } + + [Fact] + public void ContainsEquipToken_false_for_null() + { + EquipmentScriptPaths.ContainsEquipToken(null).ShouldBeFalse(); + } +}