merge worktree-agent-ad3207d913b3b74e6 (B3-WP4) into v3/batch3-uns-rework
This commit is contained in:
@@ -6,6 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
public class EquipmentScriptPathsTests
|
||||
{
|
||||
// Effective-name → backing-tag RawPath map used across the substitution tests.
|
||||
private static readonly IReadOnlyDictionary<string, string> RefMap =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Speed"] = "Cell1/Modbus/Dev1/Speed",
|
||||
["Out"] = "Cell1/Modbus/Dev1/Out",
|
||||
["A"] = "Cell1/Modbus/Dev1/A",
|
||||
["B"] = "Cell1/Modbus/Dev1/B",
|
||||
// an override-named reference: the effective name differs from the backing tag leaf name.
|
||||
["MotorSpeed"] = "Cell1/Modbus/Dev1/raw_speed_01",
|
||||
};
|
||||
|
||||
// ---- ExtractAlarmDependencyRefs (scripted-alarm dep graph; byte-parity seam) ----
|
||||
|
||||
[Fact]
|
||||
@@ -33,123 +45,81 @@ public class EquipmentScriptPathsTests
|
||||
.ShouldBe(["Line.Temp"]);
|
||||
}
|
||||
|
||||
// ---- DeriveEquipmentBase ----
|
||||
// ---- SubstituteEquipmentToken (v3 reference-relative, slash syntax) ----
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_shared_prefix()
|
||||
public void SubstituteEquipmentToken_resolves_ref_to_rawpath_inside_GetTag()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["TestMachine_001.A", "TestMachine_001.B"])
|
||||
.ShouldBe("TestMachine_001");
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}/Speed\")", RefMap)
|
||||
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_null_when_prefixes_diverge()
|
||||
public void SubstituteEquipmentToken_resolves_ref_inside_SetVirtualTag()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["TestMachine_001.A", "DelmiaReceiver_001.B"])
|
||||
.ShouldBeNull();
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.SetVirtualTag(\"{{equip}}/Out\", x)", RefMap)
|
||||
.ShouldBe("ctx.SetVirtualTag(\"Cell1/Modbus/Dev1/Out\", x)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_null_for_empty_sequence()
|
||||
public void SubstituteEquipmentToken_override_named_ref_resolves_to_its_backing_rawpath()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase([])
|
||||
.ShouldBeNull();
|
||||
// Effective name "MotorSpeed" (a DisplayNameOverride) resolves to the backing tag's RawPath whose
|
||||
// leaf name ("raw_speed_01") differs from the effective name — the override indirection.
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}/MotorSpeed\")", RefMap)
|
||||
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/raw_speed_01\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_whole_value_when_no_dot()
|
||||
public void SubstituteEquipmentToken_leaves_unresolved_ref_intact_without_throwing()
|
||||
{
|
||||
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\"");
|
||||
// An unknown reference name is left un-substituted (the validator rejects it first at deploy).
|
||||
var source = "ctx.GetTag(\"{{equip}}/Missing\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_leaves_comment_unchanged()
|
||||
{
|
||||
var source = "// uses {{equip}} here";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "// uses {{equip}}/Speed here";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_leaves_logger_string_unchanged()
|
||||
{
|
||||
var source = "ctx.Logger.Information(\"{{equip}}\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "ctx.Logger.Information(\"{{equip}}/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_all_occurrences()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.A\"); ctx.GetTag(\"{{equip}}.B\");";
|
||||
var source = "ctx.GetTag(\"{{equip}}/A\"); ctx.GetTag(\"{{equip}}/B\");";
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap);
|
||||
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.A\")");
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.B\")");
|
||||
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/A\")");
|
||||
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/B\")");
|
||||
result.ShouldNotContain(EquipmentScriptPaths.EquipToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_null()
|
||||
public void SubstituteEquipmentToken_is_identity_when_map_empty()
|
||||
{
|
||||
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, "")
|
||||
var source = "ctx.GetTag(\"{{equip}}/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
source, new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.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);
|
||||
var source = "ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -157,10 +127,59 @@ public class EquipmentScriptPathsTests
|
||||
{
|
||||
// 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\"\"\")";
|
||||
var source = "ctx.GetTag(\"\"\"{{equip}}/Speed\"\"\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
// ---- ExtractEquipReferenceNames (script path-literal scoped) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_returns_distinct_refs_first_seen()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}/Speed\"); ctx.SetVirtualTag(\"{{equip}}/Out\", x); ctx.GetTag(\"{{equip}}/Speed\");";
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Speed", "Out"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_ignores_token_in_comment_or_logger()
|
||||
{
|
||||
// Only ctx.GetTag/SetVirtualTag path literals are scanned — a token in a comment / logger string
|
||||
// is NOT reported (so it can never block a deploy).
|
||||
var source = "// {{equip}}/InComment\nctx.Logger.Information(\"{{equip}}/InLog\"); ctx.GetTag(\"{{equip}}/Real\");";
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Real"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_supports_ref_names_with_spaces()
|
||||
{
|
||||
// The whole post-prefix literal content is the reference name — so a space-bearing effective name
|
||||
// (valid raw name) is captured intact, matching what substitution resolves.
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames("ctx.GetTag(\"{{equip}}/My Tag\")")
|
||||
.ShouldBe(["My Tag"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("ctx.GetTag(\"Absolute/Path\")")]
|
||||
public void ExtractEquipReferenceNames_empty_when_no_token(string? source)
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- ExtractEquipReferenceNamesFromText (message-template free text) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNamesFromText_captures_refs_bounded_by_whitespace()
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("Temp {{equip}}/Speed exceeded on {{equip}}/Limit")
|
||||
.ShouldBe(["Speed", "Limit"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNamesFromText_empty_when_no_token()
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("plain message {Foo.Bar}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- ExtractDependencyRefs ----
|
||||
@@ -197,7 +216,7 @@ public class EquipmentScriptPathsTests
|
||||
[Fact]
|
||||
public void ContainsEquipToken_true_when_token_present()
|
||||
{
|
||||
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}.X\")").ShouldBeTrue();
|
||||
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}/X\")").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +236,7 @@ public class EquipmentScriptPathsTests
|
||||
[Theory]
|
||||
[InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")]
|
||||
[InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")]
|
||||
[InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")]
|
||||
[InlineData("return ctx.GetTag(\"{{equip}}/Speed\").Value;", "{{equip}}/Speed")]
|
||||
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
|
||||
{
|
||||
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
|
||||
|
||||
@@ -297,6 +297,117 @@ public sealed class DraftValidatorTests
|
||||
DisplayNameOverride = overrideName,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// ValidateEquipReferenceResolution — v3 WP4: {{equip}}/<RefName> must resolve to a reference
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
private static Script BuildScript(string id, string source) => new()
|
||||
{
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = $"h-{id}",
|
||||
};
|
||||
|
||||
private static Tag BuildRawTag(string tagId, string name) => new()
|
||||
{
|
||||
TagId = tagId, DeviceId = "dev-1", Name = name, DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static UnsTagReference BuildReference(string id, string equipmentId, string tagId, string? overrideName = null) => new()
|
||||
{
|
||||
UnsTagReferenceId = id, EquipmentId = equipmentId, TagId = tagId, DisplayNameOverride = overrideName,
|
||||
};
|
||||
|
||||
/// <summary>A VirtualTag script using <c>{{equip}}/Speed</c> with no reference "Speed" on the equipment is
|
||||
/// a deploy error naming the equipment + the missing ref.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_unresolved_is_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
};
|
||||
|
||||
var err = DraftValidator.Validate(draft).First(e => e.Code == "EquipReferenceUnresolved");
|
||||
err.Message.ShouldContain("eq-1");
|
||||
err.Message.ShouldContain("Speed");
|
||||
}
|
||||
|
||||
/// <summary>The same script resolves cleanly when the equipment has a reference whose effective name is
|
||||
/// "Speed" (backing raw tag's Name).</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolved_is_clean()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "Speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>A DisplayNameOverride is the effective name: <c>{{equip}}/MotorSpeed</c> resolves to a
|
||||
/// reference overridden to "MotorSpeed" even though the backing raw tag's Name is "raw_speed".</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolves_by_override_name()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/MotorSpeed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "raw_speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1", overrideName: "MotorSpeed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>A reference on a DIFFERENT equipment does not satisfy the token — resolution is per owning
|
||||
/// equipment.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_does_not_resolve_across_equipment()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "Speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-2", "tag-1")], // reference is on eq-2, not eq-1
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>Alarm message-template <c>{{equip}}/Temp</c> follows the same rule — an unresolved ref in the
|
||||
/// template is a deploy error.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarm_message_template_equip_ref_unresolved_is_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return true;")],
|
||||
ScriptedAlarms =
|
||||
[
|
||||
new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "overheat",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "Too hot: {{equip}}/Temp",
|
||||
PredicateScriptId = "s-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user