using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; public sealed class DiagnoseTests { private static readonly ScriptAnalysisService Svc = new(); private static IReadOnlyList Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers; [Fact] public void Clean_script_has_no_markers() => Diag("""return (double)ctx.GetTag("A").Value * 2.0;""").ShouldBeEmpty(); [Fact] public void Roslyn_error_is_reported_on_user_line_1() { var m = Diag("return ctx.NoSuchMember();"); m.ShouldNotBeEmpty(); m.ShouldContain(x => x.Severity == 8); m.First(x => x.Severity == 8).StartLineNumber.ShouldBe(1); // #line 1 → user coords (GetMappedLineSpan) } [Fact] public void Roslyn_error_maps_to_correct_user_line() { // error on the THIRD user line — proves mapping isn't off by the preamble height var m = Diag("var a = 1;\nvar b = 2;\nreturn ctx.Nope();"); m.ShouldContain(x => x.Severity == 8 && x.StartLineNumber == 3); } [Fact] public void Forbidden_or_unresolved_dangerous_type_is_flagged() => Diag("""var c = new System.Net.Http.HttpClient(); return 0;""") .ShouldContain(x => x.Severity == 8); // Sandbox hardening parity (R05:10): the editor runs the SAME ForbiddenTypeAnalyzer the publish gate // runs, so every newly-denied form must surface as an OTSCRIPT_FORBIDDEN marker here — editor accepts // ⇔ publish accepts. One case per deny mechanism (namespace, member, type), plus the allow-list. [Theory] [InlineData("""new System.Threading.SemaphoreSlim(0).Wait(); return 0;""")] // System.Threading namespace [InlineData("""System.Threading.SpinWait.SpinUntil(() => false); return 0;""")] [InlineData("""var t = System.Type.GetType("System.IO.File"); return t is null ? 0 : 1;""")] // forbidden member [InlineData("""return typeof(string).GetMethods().Length;""")] [InlineData("""var o = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(string)); return 0;""")] // forbidden type public void Hardened_sandbox_forms_are_flagged_in_the_editor(string code) => Diag(code).ShouldContain(x => x.Code == "OTSCRIPT_FORBIDDEN" && x.Severity == 8); [Theory] [InlineData("""var t = System.Threading.CancellationToken.None; return t.IsCancellationRequested ? 1 : 0;""")] [InlineData("""var x = 41; System.Threading.Interlocked.Increment(ref x); return x;""")] [InlineData("""var x = 42; return System.Threading.Volatile.Read(ref x);""")] [InlineData("""object o = 42; return o.GetType().IsValueType ? 1 : 0;""")] [InlineData("""ctx.CancellationToken.ThrowIfCancellationRequested(); return ctx.CancellationToken.IsCancellationRequested ? 1 : 0;""")] public void Allow_listed_forms_are_not_flagged_in_the_editor(string code) => Diag(code).ShouldBeEmpty(); [Fact] public void Dynamic_tag_path_is_flagged() => Diag("""var p = "A"; return ctx.GetTag(p).Value;""") .ShouldContain(x => x.Message.Contains("literal")); [Fact] public void Missing_return_does_not_produce_a_phantom_preamble_marker() => Diag("var x = 1;").ShouldNotContain(m => m.Code == "CS0161"); [Fact] public void Empty_code_has_no_markers() => Diag("").ShouldBeEmpty(); [Fact] public void Crlf_source_maps_dynamic_path_to_the_correct_line() { // \r\n line endings; the dynamic-path rejection must land on user line 3, not be column-shifted. var m = Diag("var a = 1;\r\nvar b = 2;\r\nreturn ctx.GetTag(b > 0 ? \"x\" : \"y\").Value;"); m.ShouldContain(x => x.Message.Contains("literal") && x.StartLineNumber == 3); } }