using System.Collections.Concurrent; 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; /// /// lmxopcua#780 — with Scripting:Runner=OutOfProcess selecting the Calculation seam, the driver hands each /// compiled run to the node's : keyed by the calc tag's RawPath, with the driver's own /// budget. A runner-side failure publishes BadResourceUnavailable (0x80040000); a script failure keeps /// BadInternalError. The passthrough fast path never reaches the runner. /// public sealed class CalculationDriverScriptRunnerTests { private const uint StatusGood = 0u; private const uint StatusBadInternalError = 0x80020000u; private const uint StatusBadResourceUnavailable = 0x80040000u; private const string Tag = "calc/e/x"; private sealed class FakeRunner : IScriptRunner { public ConcurrentQueue Outcomes { get; } = new(); public ConcurrentQueue Invocations { get; } = new(); public List Resets { get; } = []; public Task RunAsync(ScriptInvocation invocation, CancellationToken ct = default) { Invocations.Enqueue(invocation); return Task.FromResult(Outcomes.TryDequeue(out var next) ? next : ScriptRunOutcome.Ok(null)); } public void Reset(ScriptContextKind kind) => Resets.Add(kind); public IReadOnlyCollection QuarantinedKeys(ScriptContextKind kind) => ["q-" + kind]; } private static RawTagEntry CalcTag(string source) { var json = $"{{\"scriptId\":\"s\",\"changeTriggered\":true,\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}"; return new RawTagEntry(Tag, json, WriteIdempotent: false); } private static async Task Settle(CalculationDriver driver) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); while (DateTime.UtcNow < deadline && !driver.IsEvaluationIdle) await Task.Delay(10); driver.IsEvaluationIdle.ShouldBeTrue(); } private static (CalculationDriver Driver, ConcurrentQueue Published) Build(FakeRunner runner, string source) { var driver = new CalculationDriver( new CalculationDriverOptions { RawTags = [CalcTag(source)], RunTimeout = TimeSpan.FromMilliseconds(1234), ScriptRunner = runner }, "calc-drv", new ScriptRootLogger(new LoggerConfiguration().CreateLogger())); var published = new ConcurrentQueue(); driver.OnDataChange += (_, e) => published.Enqueue(e); return (driver, published); } [Fact] public async Task A_runner_failure_publishes_BadResourceUnavailable_and_a_script_failure_BadInternalError() { var runner = new FakeRunner(); runner.Outcomes.Enqueue(ScriptRunOutcome.Ok(30.0)); runner.Outcomes.Enqueue(ScriptRunOutcome.Unavailable(ScriptRunOutcome.WorkerRestartedReason)); runner.Outcomes.Enqueue(ScriptRunOutcome.Failed("script threw: boom")); var (driver, published) = Build(runner, "return (double)ctx.GetTag(\"src/a\").Value * 3;"); await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow); await Settle(driver); published.TryDequeue(out var good).ShouldBeTrue(); good!.Snapshot.StatusCode.ShouldBe(StatusGood); good.Snapshot.Value.ShouldBe(30.0); var invocation = runner.Invocations.Single(); invocation.Key.ShouldBe(Tag); invocation.Kind.ShouldBe(ScriptContextKind.Calculation); invocation.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1234)); invocation.Inputs["src/a"].Value.ShouldBe(10.0); driver.OnDependencyValue("src/a", 11.0, StatusGood, DateTime.UtcNow); await Settle(driver); published.TryDequeue(out var unavailable).ShouldBeTrue(); unavailable!.Snapshot.StatusCode.ShouldBe(StatusBadResourceUnavailable); unavailable.Snapshot.Value.ShouldBe(30.0, "the last known value rides out with the Bad status"); driver.OnDependencyValue("src/a", 12.0, StatusGood, DateTime.UtcNow); await Settle(driver); published.TryDequeue(out var scriptFault).ShouldBeTrue(); scriptFault!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError); } [Fact] public async Task The_passthrough_fast_path_never_reaches_the_runner() { var runner = new FakeRunner(); var (driver, published) = Build(runner, "return ctx.GetTag(\"src/a\").Value;"); await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); driver.OnDependencyValue("src/a", 4.0, StatusGood, DateTime.UtcNow); await Settle(driver); published.TryDequeue(out var mirrored).ShouldBeTrue(); mirrored!.Snapshot.Value.ShouldBe(4.0); runner.Invocations.ShouldBeEmpty(); } [Fact] public void The_deploy_boundary_resets_the_runners_calculation_seam_and_quarantines_are_reported() { var runner = new FakeRunner(); using var evaluator = new CalculationEvaluator(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, new ScriptRootLogger(new LoggerConfiguration().CreateLogger()), scriptRunner: runner); evaluator.ClearCompiledScripts(); evaluator.ResetQuarantines(); runner.Resets.ShouldBe([ScriptContextKind.Calculation, ScriptContextKind.Calculation]); evaluator.QuarantinedScripts.ShouldBe(["q-Calculation"]); } }