using System.Reflection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests; /// /// T0-4 — parity tests for against the VirtualTag /// RoslynVirtualTagEvaluator it mirrors: the passthrough fast-path, the wall-clock timeout, /// sandbox-violation rejection, single-tag SetVirtualTag drop, compile-error surfacing, and /// the compile-cache reuse / clear contract. All script faults surface as /// VirtualTagEvalResult.Failure (never a thrown exception) so WP7 can map them to Bad quality. /// public sealed class CalculationEvaluatorTests { /// Builds a no-op for tests that don't assert on script logging. private static ScriptRootLogger NoOpScriptRoot() => new(new LoggerConfiguration().CreateLogger()); // ── passthrough fast-path ─────────────────────────────────────────────────────────────── /// The mirror shape return ctx.GetTag("X").Value; returns the dependency value /// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path). [Fact] public void Passthrough_returns_dependency_value_without_compiling() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var result = sut.Evaluate( calcTagId: "calc-mirror", scriptSource: "return ctx.GetTag(\"a\").Value;", dependencies: new Dictionary { ["a"] = 42 }); result.Success.ShouldBeTrue(result.Reason); result.Value.ShouldBe(42); } /// Decisive proof the fast-path skips Roslyn: the compiled-script cache stays EMPTY after a /// mirror evaluation, then grows to one entry once a genuine (non-mirror) expression forces compilation. [Fact] public void Passthrough_does_not_populate_the_compiled_script_cache() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;", new Dictionary { ["a"] = 1 }); CompiledCacheCount(sut).ShouldBe(0); sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;", new Dictionary { ["a"] = 1 }); CompiledCacheCount(sut).ShouldBe(1); } // ── timeout ───────────────────────────────────────────────────────────────────────────── /// An infinite-loop script returns Failure ("timed out") within the wall-clock budget instead /// of wedging the caller. The whole call is WaitAsync-bounded so a regression FAILS rather than hangs. /// /// A CPU-bound script that outlives the 200 ms run timeout by 25x but still ENDS. A timed-out script /// is abandoned, not stopped, so the old while(true){} pinned one core for the rest of the test /// process — starving every parallel suite on a small CI runner (#606). System.DateTime is /// outside the sandbox deny-list. /// private const string BoundedSpinScript = "var end = System.DateTime.UtcNow.AddSeconds(5); while (System.DateTime.UtcNow < end) { } return 0;"; [Fact] public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang() { using var sut = new CalculationEvaluator( NullLogger.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200)); var evalTask = Task.Run( () => sut.Evaluate("calc-loop", BoundedSpinScript, new Dictionary()), TestContext.Current.CancellationToken); // The outer bound covers the Roslyn COMPILE as well as the run — the 200 ms run timeout starts // only once the script is compiled — and a cold compile on a loaded CI runner took the old 5 s // budget on every one of four consecutive Gitea runs (#606). 30 s is still a hard bound, so a // hang still fails the test; and because the spin ends after 5 s, a timeout that never fires // now fails it too (Success == true), rather than only failing as a hang. var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); result.Success.ShouldBeFalse(); result.Reason.ShouldNotBeNull(); result.Reason.ShouldContain("timed out"); } // ── sandbox violation ─────────────────────────────────────────────────────────────────── /// A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects /// it: the ScriptSandboxViolationException from compile is caught and returned as a Failure whose /// reason names the sandbox violation — never propagated as an exception. [Fact] public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var result = sut.Evaluate( calcTagId: "calc-fileio", scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");", dependencies: new Dictionary()); result.Success.ShouldBeFalse(); result.Reason.ShouldNotBeNull(); result.Reason.ShouldContain("sandbox violation"); } // ── single-tag mode: ctx.SetVirtualTag drop ───────────────────────────────────────────── /// Single-tag mode: a script calling ctx.SetVirtualTag has that cross-tag write dropped /// (logged at Debug) while the script's own return value — the calc tag's single output — is preserved. [Fact] public void SetVirtualTag_call_is_dropped_and_logged_single_output_preserved() { var logger = new CapturingLogger(); using var sut = new CalculationEvaluator(logger, NoOpScriptRoot()); var result = sut.Evaluate( calcTagId: "calc-fanout", scriptSource: "ctx.SetVirtualTag(\"other\", 99); return (int)ctx.GetTag(\"a\").Value + 1;", dependencies: new Dictionary { ["a"] = 41 }); // The write to "other" is dropped, but the calc tag's own output flows through unaffected. result.Success.ShouldBeTrue(result.Reason); result.Value.ShouldBe(42); // The drop was logged (single-tag evaluator), never applied. logger.Entries.ShouldContain(e => e.Level == LogLevel.Debug && e.Message.Contains("dropped") && e.Message.Contains("other")); } // ── compile error ─────────────────────────────────────────────────────────────────────── /// A compile error returns Failure with a descriptive reason (parity with the VT evaluator). [Fact] public void Evaluate_compile_error_returns_Failure_with_reason() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary()); result.Success.ShouldBeFalse(); result.Reason.ShouldNotBeNull(); result.Reason.ShouldContain("compile"); } /// A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator). [Fact] public void Evaluate_runtime_exception_returns_Failure_with_reason() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var result = sut.Evaluate( calcTagId: "calc-div0", scriptSource: "int a = 0; return 1 / a;", dependencies: new Dictionary()); result.Success.ShouldBeFalse(); result.Reason.ShouldNotBeNull(); result.Reason.ShouldContain("threw"); } // ── cache reuse + clear ───────────────────────────────────────────────────────────────── /// A compiled (non-mirror) expression is cached and re-used across calls: two evaluations of the /// same source produce fresh values but leave the cache at exactly one entry. [Fact] public void Evaluate_caches_compiled_expression_across_calls() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;"; var first = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 5 }); var second = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 7 }); first.Success.ShouldBeTrue(first.Reason); first.Value.ShouldBe(10); second.Success.ShouldBeTrue(second.Reason); second.Value.ShouldBe(14); CompiledCacheCount(sut).ShouldBe(1); } /// drops every cached compile — the hook /// WP7 calls per deploy generation to release stale AssemblyLoadContexts — and the same source recompiles /// cleanly afterward. [Fact] public void ClearCompiledScripts_empties_cache_then_recompiles() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;"; sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 1 }); CompiledCacheCount(sut).ShouldBe(1); sut.ClearCompiledScripts(); CompiledCacheCount(sut).ShouldBe(0); var again = sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 41 }); again.Success.ShouldBeTrue(again.Reason); again.Value.ShouldBe(42); CompiledCacheCount(sut).ShouldBe(1); } // ── guard rails ───────────────────────────────────────────────────────────────────────── /// Empty / whitespace scripts return Failure (parity with the VT evaluator). [Fact] public void Evaluate_empty_expression_returns_Failure() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); sut.Evaluate("calc-empty", "", new Dictionary()).Success.ShouldBeFalse(); sut.Evaluate("calc-empty", " ", new Dictionary()).Success.ShouldBeFalse(); } /// Evaluation after disposal returns Failure ("disposed") rather than throwing. [Fact] public void Evaluate_after_dispose_returns_Failure() { var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); sut.Dispose(); var result = sut.Evaluate("calc", "return 1;", new Dictionary()); result.Success.ShouldBeFalse(); result.Reason!.ShouldContain("disposed"); } /// Reads the count of the private CompiledScriptCache (which exposes a Count /// property) via reflection — mirrors the VT evaluator test's cache-count probe. private static int CompiledCacheCount(CalculationEvaluator sut) { var field = typeof(CalculationEvaluator) .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); field.ShouldNotBeNull(); var cache = field.GetValue(sut)!; var countProp = cache.GetType().GetProperty("Count"); countProp.ShouldNotBeNull(); return (int)countProp.GetValue(cache)!; } // ── input quality reaches the script (archreview 05/STAB-19) ──────────────────────────── /// /// A script reading ctx.GetTag(path).StatusCode sees the input's real status. /// The read cache used to stamp every dependency Good, so a script that checked quality was lied /// to — and, since the driver now holds on Bad, an Uncertain input is precisely the case where a /// script can still make a qualified decision and needs the truth to do it. /// [Fact] public void The_read_cache_carries_the_inputs_real_status_code() { const uint uncertain = 0x40000000u; using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var sourceStamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc); var result = sut.Evaluate( calcTagId: "calc-quality", scriptSource: "return (long)ctx.GetTag(\"a\").StatusCode;", dependencies: new Dictionary { ["a"] = new(7.0, uncertain, sourceStamp, sourceStamp), }); result.Success.ShouldBeTrue(result.Reason); result.Value.ShouldBe((long)uncertain); } /// /// The dependency's own source timestamp reaches the script too. The old cache stamped /// evaluation time on every input, so a script comparing input ages compared its own clock with /// itself. /// [Fact] public void The_read_cache_carries_the_inputs_real_source_timestamp() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var sourceStamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc); var result = sut.Evaluate( calcTagId: "calc-stamp", scriptSource: "return ctx.GetTag(\"a\").SourceTimestampUtc;", dependencies: new Dictionary { ["a"] = new(7.0, 0u, sourceStamp, DateTime.UtcNow), }); result.Success.ShouldBeTrue(result.Reason); result.Value.ShouldBe(sourceStamp); } /// The passthrough fast path takes the same snapshot input and returns its value. [Fact] public void The_passthrough_fast_path_accepts_snapshots() { using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); var result = sut.Evaluate( calcTagId: "calc-mirror", scriptSource: "return ctx.GetTag(\"a\").Value;", dependencies: new Dictionary { ["a"] = new(42, 0u, DateTime.UtcNow, DateTime.UtcNow), }); result.Success.ShouldBeTrue(result.Reason); result.Value.ShouldBe(42); } /// Minimal that records emitted entries so the SetVirtualTag-drop /// test can assert the Debug "dropped" line was logged. private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((logLevel, formatter(state, exception))); } }