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 RefMap = new Dictionary(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(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(); } }