using System.Reflection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
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.
[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", "while(true){}return 0;", new Dictionary()),
TestContext.Current.CancellationToken);
var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), 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)!;
}
/// 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)));
}
}