feat(commons): EquipmentScriptPaths — derive base + {{equip}} substitution + shared dep extraction

This commit is contained in:
Joseph Doherty
2026-06-10 07:42:14 -04:00
parent 26bccd82e0
commit f431504825
2 changed files with 282 additions and 0 deletions
@@ -0,0 +1,95 @@
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Helpers for equipment-relative virtual-tag script paths. The reserved token
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
/// replaced at the compose seams with the owning equipment's tag base prefix (derived
/// from its child-tag <c>FullName</c>s). 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 <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to
/// duplicate.
/// </summary>
public static class EquipmentScriptPaths
{
/// <summary>The reserved equipment-base token.</summary>
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);
/// <summary>True when the source uses the <c>{{equip}}</c> token anywhere.</summary>
/// <param name="source">The script source to scan.</param>
public static bool ContainsEquipToken(string? source) =>
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
/// <summary>
/// Equipment tag base = the single shared substring-before-first-dot across the
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
/// </summary>
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param>
/// <returns>The shared base prefix, or null when none/ambiguous.</returns>
public static string? DeriveEquipmentBase(IEnumerable<string?> 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;
}
/// <summary>
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing
/// script — none of which use the token — is byte-unchanged).
/// </summary>
/// <param name="source">The script source.</param>
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param>
/// <returns>The source with the token substituted inside path literals.</returns>
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);
}
/// <summary>
/// Distinct <c>ctx.GetTag("ref")</c> string literals in first-seen order — the
/// dependency refs the <c>VirtualTagActor</c> subscribes to. The single shared copy
/// formerly duplicated in <c>Phase7Composer</c> + <c>DeploymentArtifact</c>. GetTag
/// only (writes are not dependencies).
/// </summary>
/// <param name="scriptSource">The (already substituted) script source.</param>
/// <returns>Distinct read refs, first-seen order.</returns>
public static IReadOnlyList<string> ExtractDependencyRefs(string? scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (Match m in GetTagRefRegex.Matches(scriptSource))
{
var r = m.Groups[1].Value;
if (seen.Add(r)) result.Add(r);
}
return result;
}
}
@@ -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();
}
}