dc0d7653b9
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".
Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
takes the equipment's reference map (effectiveName -> RawPath) and substitutes
{{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
add ExtractEquipReferenceNames (path-literal scoped) +
ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
sides) over RawPathResolver — the single authority both compose seams + the
validator use, so resolved RawPaths agree byte-for-byte.
Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
the per-equipment reference map from UnsTagReferences + Tags + raw topology and
substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
extraction (runtime dep refs = resolved RawPaths).
Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).
Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
259 lines
9.8 KiB
C#
259 lines
9.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
|
|
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]
|
|
public void ExtractAlarmDependencyRefs_predicate_reads_first_then_template_tokens_deduped()
|
|
{
|
|
// A template token identical to a predicate read appears once (predicate-first); a distinct
|
|
// template token is appended after. This dedup/order is the parity contract both the composer
|
|
// and the artifact decode rely on.
|
|
EquipmentScriptPaths
|
|
.ExtractAlarmDependencyRefs(
|
|
predicateSource: "return ctx.GetTag(\"Mach.Temp\").Value > 90;",
|
|
messageTemplate: "Temp {Mach.Temp} over {Mach.Limit}")
|
|
.ShouldBe(["Mach.Temp", "Mach.Limit"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractAlarmDependencyRefs_excludes_reserved_equip_double_brace_token()
|
|
{
|
|
// The reserved {{equip}} double-brace form must NOT be picked up as a single-brace {TagPath}
|
|
// token; only the genuine {Line.Temp} token is extracted.
|
|
EquipmentScriptPaths
|
|
.ExtractAlarmDependencyRefs(
|
|
predicateSource: null,
|
|
messageTemplate: "{{equip}} too hot: {Line.Temp}")
|
|
.ShouldBe(["Line.Temp"]);
|
|
}
|
|
|
|
// ---- SubstituteEquipmentToken (v3 reference-relative, slash syntax) ----
|
|
|
|
[Fact]
|
|
public void SubstituteEquipmentToken_resolves_ref_to_rawpath_inside_GetTag()
|
|
{
|
|
EquipmentScriptPaths.SubstituteEquipmentToken(
|
|
"ctx.GetTag(\"{{equip}}/Speed\")", RefMap)
|
|
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")");
|
|
}
|
|
|
|
[Fact]
|
|
public void SubstituteEquipmentToken_resolves_ref_inside_SetVirtualTag()
|
|
{
|
|
EquipmentScriptPaths.SubstituteEquipmentToken(
|
|
"ctx.SetVirtualTag(\"{{equip}}/Out\", x)", RefMap)
|
|
.ShouldBe("ctx.SetVirtualTag(\"Cell1/Modbus/Dev1/Out\", x)");
|
|
}
|
|
|
|
[Fact]
|
|
public void SubstituteEquipmentToken_override_named_ref_resolves_to_its_backing_rawpath()
|
|
{
|
|
// 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 SubstituteEquipmentToken_leaves_unresolved_ref_intact_without_throwing()
|
|
{
|
|
// 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}}/Speed here";
|
|
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
|
}
|
|
|
|
[Fact]
|
|
public void SubstituteEquipmentToken_leaves_logger_string_unchanged()
|
|
{
|
|
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 result = EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap);
|
|
|
|
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_map_empty()
|
|
{
|
|
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(\"Cell1/Modbus/Dev1/Speed\")";
|
|
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).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}}/Speed\"\"\")";
|
|
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).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 ----
|
|
|
|
[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();
|
|
}
|
|
|
|
// ---- TryParseRelayBody ----
|
|
|
|
[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")]
|
|
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
|
|
{
|
|
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
|
|
r.ShouldBe(expectedRef);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("return ctx.GetTag(\"A.B\").Value * 2;")]
|
|
[InlineData("var x = ctx.GetTag(\"A.B\").Value; return x;")]
|
|
[InlineData("return ctx.GetTag(\"A.B\").Value + ctx.GetTag(\"C.D\").Value;")]
|
|
[InlineData("return ctx.GetTag(\"A.B\").Quality;")]
|
|
[InlineData("return 5;")]
|
|
[InlineData("")]
|
|
public void TryParseRelayBody_rejects_non_relay(string src)
|
|
{
|
|
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeFalse();
|
|
r.ShouldBeNull();
|
|
}
|
|
}
|