- Core.Scripting-005: DependencyExtractor.HandleTagCall now recognises raw-string literal paths by checking the StringLiteralExpression node kind instead of the legacy StringLiteralToken kind. - Core.Scripting-006: scope CompiledScriptCache failed-compile eviction with TryRemove(KeyValuePair) so a racing retry entry is not evicted. - Core.Scripting-008: document the per-publish assembly accretion as an accepted limitation in docs/VirtualTags.md. - Core.Scripting-009: enumerate the authoritative deny-list (namespace prefixes + type-granular denies) in the Phase 7 decision-#6 entry to match ForbiddenTypeAnalyzer. - Core.Scripting-011: pin ScriptSandbox.Build, ScriptContext.Deadband boundary semantics, and end-to-end factory + companion-sink integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
8.3 KiB
C#
242 lines
8.3 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|