using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; /// /// Exercises the AST walker that extracts static tag dependencies from user scripts /// + rejects every form of non-literal path. Locks the parse shape the virtual-tag /// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2). /// [Trait("Category", "Unit")] public sealed class DependencyExtractorTests { [Fact] public void Extracts_single_literal_read() { var result = DependencyExtractor.Extract( """return ctx.GetTag("Line1/Speed").Value;"""); result.IsValid.ShouldBeTrue(); result.Reads.ShouldContain("Line1/Speed"); result.Writes.ShouldBeEmpty(); result.Rejections.ShouldBeEmpty(); } [Fact] public void Extracts_multiple_distinct_reads() { var result = DependencyExtractor.Extract( """ var a = ctx.GetTag("Line1/A").Value; var b = ctx.GetTag("Line1/B").Value; return (double)a + (double)b; """); result.IsValid.ShouldBeTrue(); result.Reads.Count.ShouldBe(2); result.Reads.ShouldContain("Line1/A"); result.Reads.ShouldContain("Line1/B"); } [Fact] public void Deduplicates_identical_reads_across_the_script() { var result = DependencyExtractor.Extract( """ if (((double)ctx.GetTag("X").Value) > 0) return ctx.GetTag("X").Value; return 0; """); result.IsValid.ShouldBeTrue(); result.Reads.Count.ShouldBe(1); result.Reads.ShouldContain("X"); } [Fact] public void Tracks_virtual_tag_writes_separately_from_reads() { var result = DependencyExtractor.Extract( """ var v = (double)ctx.GetTag("InTag").Value; ctx.SetVirtualTag("OutTag", v * 2); return v; """); result.IsValid.ShouldBeTrue(); result.Reads.ShouldContain("InTag"); result.Writes.ShouldContain("OutTag"); result.Reads.ShouldNotContain("OutTag"); result.Writes.ShouldNotContain("InTag"); } [Fact] public void Rejects_variable_path() { var result = DependencyExtractor.Extract( """ var path = "Line1/Speed"; return ctx.GetTag(path).Value; """); result.IsValid.ShouldBeFalse(); result.Rejections.Count.ShouldBe(1); result.Rejections[0].Message.ShouldContain("string literal"); } [Fact] public void Rejects_concatenated_path() { var result = DependencyExtractor.Extract( """return ctx.GetTag("Line1/" + "Speed").Value;"""); result.IsValid.ShouldBeFalse(); result.Rejections[0].Message.ShouldContain("string literal"); } [Fact] public void Rejects_interpolated_path() { var result = DependencyExtractor.Extract( """ var n = 1; return ctx.GetTag($"Line{n}/Speed").Value; """); result.IsValid.ShouldBeFalse(); result.Rejections[0].Message.ShouldContain("string literal"); } [Fact] public void Rejects_method_returned_path() { var result = DependencyExtractor.Extract( """ string BuildPath() => "Line1/Speed"; return ctx.GetTag(BuildPath()).Value; """); result.IsValid.ShouldBeFalse(); result.Rejections[0].Message.ShouldContain("string literal"); } [Fact] public void Rejects_empty_literal_path() { var result = DependencyExtractor.Extract( """return ctx.GetTag("").Value;"""); result.IsValid.ShouldBeFalse(); result.Rejections[0].Message.ShouldContain("empty"); } [Fact] public void Rejects_whitespace_only_path() { var result = DependencyExtractor.Extract( """return ctx.GetTag(" ").Value;"""); result.IsValid.ShouldBeFalse(); } [Fact] public void Ignores_non_ctx_method_named_GetTag_free_function() { // Scripts are free to define their own helper called "GetTag" — as long as it's // not on the ctx instance, the extractor doesn't pick it up. The sandbox // compile will still reject any path that isn't on the ScriptContext type. var result = DependencyExtractor.Extract( """ string helper_GetTag(string p) => p; return helper_GetTag("NotATag"); """); result.IsValid.ShouldBeTrue(); result.Reads.ShouldBeEmpty(); } [Fact] public void Ignores_member_access_GetTag_on_non_ctx_receiver() { // A member-access call to GetTag on a non-ctx identifier must NOT be treated as // a ScriptContext dependency. The old walker accepted any receiver; the fix // requires the receiver to be the identifier "ctx". (Core.Scripting-004.) var result = DependencyExtractor.Extract( """ class Helper { public object GetTag(string p) => p; } var h = new Helper(); var v = h.GetTag("X"); return ctx.GetTag("RealTag").Value; """); result.IsValid.ShouldBeTrue(); result.Reads.ShouldContain("RealTag"); result.Reads.ShouldNotContain("X"); } [Fact] public void Empty_source_is_a_no_op() { DependencyExtractor.Extract("").IsValid.ShouldBeTrue(); DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue(); DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue(); } [Fact] public void Rejection_carries_source_span_for_UI_pointing() { // Offending path at column 23-29 in the source — Admin UI uses Span to // underline the exact token. const string src = """return ctx.GetTag(path).Value;"""; var result = DependencyExtractor.Extract(src); result.IsValid.ShouldBeFalse(); result.Rejections[0].Span.Start.ShouldBeGreaterThan(0); result.Rejections[0].Span.Length.ShouldBeGreaterThan(0); } [Fact] public void Multiple_bad_paths_all_reported_in_one_pass() { var result = DependencyExtractor.Extract( """ var p1 = "A"; var p2 = "B"; return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString(); """); result.IsValid.ShouldBeFalse(); result.Rejections.Count.ShouldBe(2); } [Fact] public void Nested_literal_GetTag_inside_expression_is_extracted() { // Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args // are captured even when the enclosing expression is complex. var result = DependencyExtractor.Extract( """ return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value); """); result.IsValid.ShouldBeTrue(); result.Reads.Count.ShouldBe(2); } [Fact] public void Accepts_single_line_raw_string_literal_path() { // A single-line raw string literal ("""Line1/Speed""") tokenizes as // SingleLineRawStringLiteralToken, not StringLiteralToken — the old check // would mis-reject it as a "dynamic path". Confirm static raw-string paths are // harvested. (Core.Scripting-005.) var src = "return ctx.GetTag(\"\"\"Line1/Speed\"\"\").Value;"; var result = DependencyExtractor.Extract(src); result.IsValid.ShouldBeTrue(); result.Reads.ShouldContain("Line1/Speed"); result.Rejections.ShouldBeEmpty(); } [Fact] public void Accepts_multi_line_raw_string_literal_path() { // A multi-line raw string literal tokenizes as MultiLineRawStringLiteralToken. // Even though it is unusual for a tag path, it is still a static string and // must not be mis-rejected. (Core.Scripting-005.) // Note: the multi-line raw string strips the common leading indent and the // surrounding newlines, leaving exactly the body text. var src = "return ctx.GetTag(\"\"\"\nLine1/Speed\n\"\"\").Value;"; var result = DependencyExtractor.Extract(src); result.IsValid.ShouldBeTrue(); result.Reads.ShouldContain("Line1/Speed"); result.Rejections.ShouldBeEmpty(); } }