using System.Collections.Concurrent;
using System.Diagnostics;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
///
/// lmxopcua#780 — the whole path a client sees: a real Calculation driver whose seam runs out of process on the real
/// worker. A script failure publishes BadInternalError as today; the worker dying under a script publishes
/// BadResourceUnavailable (0x80040000), the ScadaBridge-visible code; and the tag recovers Good afterwards.
///
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class SeamEndToEndTests
{
private const uint StatusGood = 0u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadResourceUnavailable = 0x80040000u;
private const string Tag = "calc/e/spin";
/// Spins for src/ms milliseconds, throws when it is negative, and returns it otherwise.
private const string Script =
"var ms = (double)ctx.GetTag(\"src/ms\").Value; if (ms < 0) throw new System.InvalidOperationException(\"negative\"); " +
"var end = ctx.Now.AddMilliseconds(ms); while (ctx.Now < end) { } return ms;";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task Settle(CalculationDriver driver)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
while (DateTime.UtcNow < deadline && !driver.IsEvaluationIdle) await Task.Delay(10);
driver.IsEvaluationIdle.ShouldBeTrue();
}
[Fact]
public async Task A_calculation_tag_publishes_BadResourceUnavailable_only_when_the_worker_died_under_it()
{
await using var runner = Scripts.Runner(Scripts.Options());
var json = $"{{\"scriptId\":\"s\",\"changeTriggered\":true,\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(Script)}}}";
var driver = new CalculationDriver(
new CalculationDriverOptions { RawTags = [new RawTagEntry(Tag, json, WriteIdempotent: false)], RunTimeout = TimeSpan.FromSeconds(20), ScriptRunner = runner },
"calc-drv", new ScriptRootLogger(new LoggerConfiguration().CreateLogger()));
var published = new ConcurrentQueue();
driver.OnDataChange += (_, e) => published.Enqueue(e);
await driver.InitializeAsync("{}", Ct);
driver.OnDependencyValue("src/ms", 0.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.StatusCode.ShouldBe(StatusGood);
good.Snapshot.Value.ShouldBe(0.0);
// A script fault: BadInternalError, exactly as in process.
driver.OnDependencyValue("src/ms", -1.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var threw).ShouldBeTrue();
threw!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
driver.OnDependencyValue("src/ms", 1.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var back).ShouldBeTrue();
back!.Snapshot.StatusCode.ShouldBe(StatusGood);
// The worker dies under a run: the runner, not the script, failed.
driver.OnDependencyValue("src/ms", 5000.0, StatusGood, DateTime.UtcNow);
var worker = await Scripts.Eventually(() => runner.Shards[0].Current is { InFlightCount: 1 } w ? w : null,
TimeSpan.FromSeconds(10), "the run in flight");
await Task.Delay(200, Ct);
Process.GetProcessById(worker.Pid).Kill();
await Settle(driver);
published.TryDequeue(out var unavailable).ShouldBeTrue();
unavailable!.Snapshot.StatusCode.ShouldBe(StatusBadResourceUnavailable);
unavailable.Snapshot.Value.ShouldBe(1.0, "the last good value rides out with the Bad status");
// Recovery: the next trigger runs (in the isolation worker, as a suspect) and publishes Good.
driver.OnDependencyValue("src/ms", 2.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var recovered).ShouldBeTrue();
recovered!.Snapshot.StatusCode.ShouldBe(StatusGood);
recovered.Snapshot.Value.ShouldBe(2.0);
await driver.ShutdownAsync(Ct);
}
}