55ae230fa4
- v2-ci.yml: upload-artifact@v4 -> @v3 + continue-on-error (act_runner reports as GHES and v4 refuses GHES, failing every run); runner-capacity diagnostic step; AKKA_TEST_TIMEFACTOR=3 on the test step. - Runtime/ControlPlane harnesses: akka.test.timefactor from AKKA_TEST_TIMEFACTOR (TestTimeFactor), single-expect-default 15s; ControlPlane gains PresenceBudget. - Leaked spinner threads: the infinite-loop timeout tests (Calculation, Host.IntegrationTests RoslynVirtualTagEvaluator, 3x Core.Scripting) pinned a core for the rest of the test process (abandoned, never stopped). Now a bounded 5 s wall-clock spin; outer compile-inclusive bound 30 s. - Real test races fixed: ScriptedAlarmEngine push-during-drain (Inactive is also the start state + persist-before-update => wait for the Cleared save); DriverReconnectE2e (both nodes host the driver; Created[] is last-writer-wins and ForceReconnect is a no-op while Connecting); ControlPlaneStarterOrder (lost-race heal waited for a 60 s refresh tick). - Fixed sleeps -> poll-for-condition: ReconnectSupervisor LastError, Modbus deadband steps (wait for observed reads), Modbus dispose hygiene precondition, FOCAS probe-loop log. - Short presence budgets -> PresenceBudget in the Runtime/ControlPlane tests seen failing on Gitea; Calculation Settle 10 -> 30 s. No assertion removed, nothing skipped. - infra/README.md §10 CI; stale TreatWarningsAsErrors comment in Directory.Build.props. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
331 lines
16 KiB
C#
331 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// T0-4 — parity tests for <see cref="CalculationEvaluator"/> against the VirtualTag
|
|
/// <c>RoslynVirtualTagEvaluator</c> it mirrors: the passthrough fast-path, the wall-clock timeout,
|
|
/// sandbox-violation rejection, single-tag <c>SetVirtualTag</c> drop, compile-error surfacing, and
|
|
/// the compile-cache reuse / clear contract. All script faults surface as
|
|
/// <c>VirtualTagEvalResult.Failure</c> (never a thrown exception) so WP7 can map them to Bad quality.
|
|
/// </summary>
|
|
public sealed class CalculationEvaluatorTests
|
|
{
|
|
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on script logging.</summary>
|
|
private static ScriptRootLogger NoOpScriptRoot() =>
|
|
new(new LoggerConfiguration().CreateLogger());
|
|
|
|
// ── passthrough fast-path ───────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>The mirror shape <c>return ctx.GetTag("X").Value;</c> returns the dependency value
|
|
/// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path).</summary>
|
|
[Fact]
|
|
public void Passthrough_returns_dependency_value_without_compiling()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
var result = sut.Evaluate(
|
|
calcTagId: "calc-mirror",
|
|
scriptSource: "return ctx.GetTag(\"a\").Value;",
|
|
dependencies: new Dictionary<string, object?> { ["a"] = 42 });
|
|
|
|
result.Success.ShouldBeTrue(result.Reason);
|
|
result.Value.ShouldBe(42);
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[Fact]
|
|
public void Passthrough_does_not_populate_the_compiled_script_cache()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;",
|
|
new Dictionary<string, object?> { ["a"] = 1 });
|
|
CompiledCacheCount(sut).ShouldBe(0);
|
|
|
|
sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;",
|
|
new Dictionary<string, object?> { ["a"] = 1 });
|
|
CompiledCacheCount(sut).ShouldBe(1);
|
|
}
|
|
|
|
// ── timeout ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>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.</summary>
|
|
/// <summary>
|
|
/// 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 <c>while(true){}</c> pinned one core for the rest of the test
|
|
/// process — starving every parallel suite on a small CI runner (#606). <c>System.DateTime</c> is
|
|
/// outside the sandbox deny-list.
|
|
/// </summary>
|
|
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<CalculationEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));
|
|
|
|
var evalTask = Task.Run(
|
|
() => sut.Evaluate("calc-loop", BoundedSpinScript, new Dictionary<string, object?>()),
|
|
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 ───────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects
|
|
/// it: the <c>ScriptSandboxViolationException</c> from compile is caught and returned as a Failure whose
|
|
/// reason names the sandbox violation — never propagated as an exception.</summary>
|
|
[Fact]
|
|
public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
var result = sut.Evaluate(
|
|
calcTagId: "calc-fileio",
|
|
scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");",
|
|
dependencies: new Dictionary<string, object?>());
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
result.Reason.ShouldContain("sandbox violation");
|
|
}
|
|
|
|
// ── single-tag mode: ctx.SetVirtualTag drop ─────────────────────────────────────────────
|
|
|
|
/// <summary>Single-tag mode: a script calling <c>ctx.SetVirtualTag</c> has that cross-tag write dropped
|
|
/// (logged at Debug) while the script's own return value — the calc tag's single output — is preserved.</summary>
|
|
[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<string, object?> { ["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 ───────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>A compile error returns Failure with a descriptive reason (parity with the VT evaluator).</summary>
|
|
[Fact]
|
|
public void Evaluate_compile_error_returns_Failure_with_reason()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
result.Reason.ShouldContain("compile");
|
|
}
|
|
|
|
/// <summary>A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator).</summary>
|
|
[Fact]
|
|
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
var result = sut.Evaluate(
|
|
calcTagId: "calc-div0",
|
|
scriptSource: "int a = 0; return 1 / a;",
|
|
dependencies: new Dictionary<string, object?>());
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
result.Reason.ShouldContain("threw");
|
|
}
|
|
|
|
// ── cache reuse + clear ─────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>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.</summary>
|
|
[Fact]
|
|
public void Evaluate_caches_compiled_expression_across_calls()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
|
|
|
var first = sut.Evaluate("calc-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
|
var second = sut.Evaluate("calc-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });
|
|
|
|
first.Success.ShouldBeTrue(first.Reason);
|
|
first.Value.ShouldBe(10);
|
|
second.Success.ShouldBeTrue(second.Reason);
|
|
second.Value.ShouldBe(14);
|
|
CompiledCacheCount(sut).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary><see cref="CalculationEvaluator.ClearCompiledScripts"/> drops every cached compile — the hook
|
|
/// WP7 calls per deploy generation to release stale AssemblyLoadContexts — and the same source recompiles
|
|
/// cleanly afterward.</summary>
|
|
[Fact]
|
|
public void ClearCompiledScripts_empties_cache_then_recompiles()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";
|
|
|
|
sut.Evaluate("calc-clear", expr, new Dictionary<string, object?> { ["a"] = 1 });
|
|
CompiledCacheCount(sut).ShouldBe(1);
|
|
|
|
sut.ClearCompiledScripts();
|
|
CompiledCacheCount(sut).ShouldBe(0);
|
|
|
|
var again = sut.Evaluate("calc-clear", expr, new Dictionary<string, object?> { ["a"] = 41 });
|
|
again.Success.ShouldBeTrue(again.Reason);
|
|
again.Value.ShouldBe(42);
|
|
CompiledCacheCount(sut).ShouldBe(1);
|
|
}
|
|
|
|
// ── guard rails ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Empty / whitespace scripts return Failure (parity with the VT evaluator).</summary>
|
|
[Fact]
|
|
public void Evaluate_empty_expression_returns_Failure()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
sut.Evaluate("calc-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
|
sut.Evaluate("calc-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Evaluation after disposal returns Failure ("disposed") rather than throwing.</summary>
|
|
[Fact]
|
|
public void Evaluate_after_dispose_returns_Failure()
|
|
{
|
|
var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
sut.Dispose();
|
|
|
|
var result = sut.Evaluate("calc", "return 1;", new Dictionary<string, object?>());
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason!.ShouldContain("disposed");
|
|
}
|
|
|
|
/// <summary>Reads the count of the private <c>CompiledScriptCache</c> (which exposes a <c>Count</c>
|
|
/// property) via reflection — mirrors the VT evaluator test's cache-count probe.</summary>
|
|
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) ────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A script reading <c>ctx.GetTag(path).StatusCode</c> sees the input's <b>real</b> 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_read_cache_carries_the_inputs_real_status_code()
|
|
{
|
|
const uint uncertain = 0x40000000u;
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.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<string, DataValueSnapshot>
|
|
{
|
|
["a"] = new(7.0, uncertain, sourceStamp, sourceStamp),
|
|
});
|
|
|
|
result.Success.ShouldBeTrue(result.Reason);
|
|
result.Value.ShouldBe((long)uncertain);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_read_cache_carries_the_inputs_real_source_timestamp()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.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<string, DataValueSnapshot>
|
|
{
|
|
["a"] = new(7.0, 0u, sourceStamp, DateTime.UtcNow),
|
|
});
|
|
|
|
result.Success.ShouldBeTrue(result.Reason);
|
|
result.Value.ShouldBe(sourceStamp);
|
|
}
|
|
|
|
/// <summary>The passthrough fast path takes the same snapshot input and returns its value.</summary>
|
|
[Fact]
|
|
public void The_passthrough_fast_path_accepts_snapshots()
|
|
{
|
|
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
|
|
|
var result = sut.Evaluate(
|
|
calcTagId: "calc-mirror",
|
|
scriptSource: "return ctx.GetTag(\"a\").Value;",
|
|
dependencies: new Dictionary<string, DataValueSnapshot>
|
|
{
|
|
["a"] = new(42, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
|
});
|
|
|
|
result.Success.ShouldBeTrue(result.Reason);
|
|
result.Value.ShouldBe(42);
|
|
}
|
|
|
|
/// <summary>Minimal <see cref="ILogger{T}"/> that records emitted entries so the SetVirtualTag-drop
|
|
/// test can assert the Debug "dropped" line was logged.</summary>
|
|
private sealed class CapturingLogger : ILogger<CalculationEvaluator>
|
|
{
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter) =>
|
|
Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
}
|