using System.Collections.Concurrent;
using Serilog;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
///
/// Archreview 05/STAB-19 — what the Calculation driver does with a dependency's quality.
///
/// The defect. The driver stored dependency values only and ignored the status code
/// entirely, so a comms-lost input arrived as arrived, Good, null: the warm-up gate was
/// satisfied by a value that never came, (double)ctx.GetTag("x").Value threw on every trigger
/// (a "script threw" storm), and a null-tolerant script published a Good output computed from a
/// dead input — indefinitely.
///
/// The policy pinned here matches ScriptedAlarmEngine's, which is the in-tree
/// precedent: hold on Bad, propagate the worst input's status; evaluate through Uncertain and stamp
/// the output with it. Holding (rather than evaluating and degrading) is what removes the NRE
/// storm; a Bad sub-code (rather than Uncertain) is what tells the operator the value may be
/// arbitrarily old rather than merely imprecise.
///
/// ⚠️ Vacuity rule: the recovery case re-publishes an identical computed value.
/// A recovery whose value differs passes against a value-only dedup and proves nothing — it is the
/// "Bad forever" trap the v3 Batch 4 live gate found.
///
public sealed class CalculationDriverInputQualityTests
{
private const uint StatusGood = 0u;
private const uint StatusUncertain = 0x40000000u;
private const uint StatusBadNoCommunication = 0x80310000u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadWaitingForInitialData = 0x80320000u;
private const string SumTag = "calc/e/sum";
private const string SumScript = "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;";
private static RawTagEntry CalcTag(string rawPath, string source)
{
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":true," +
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
private static (CalculationDriver Driver, ConcurrentQueue Published, List Logs)
Build(params RawTagEntry[] tags)
{
var logs = new List();
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
var published = new ConcurrentQueue();
driver.OnDataChange += (_, e) => published.Enqueue(e);
return (driver, published, logs);
}
/// Local copy of the script-log capture sink (the sibling suite's is private to it).
private sealed class CapturingPublisher(List sink) : IScriptLogPublisher
{
private readonly object _lock = new();
public void Publish(ScriptLogEntry entry)
{
lock (_lock) sink.Add(entry);
}
}
private static async Task Settle(CalculationDriver driver)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
if (driver.IsEvaluationIdle) return;
await Task.Delay(10);
}
driver.IsEvaluationIdle.ShouldBeTrue("the calc driver's evaluation pump should have drained");
}
/// Drives both deps Good and drains the resulting publish, leaving a settled Good output.
private static async Task WarmedUpAsync(ConcurrentQueue published, CalculationDriver driver)
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var first).ShouldBeTrue();
first!.Snapshot.Value.ShouldBe(30.0);
first.Snapshot.StatusCode.ShouldBe(StatusGood);
return driver;
}
///
/// A Bad input holds the script and publishes the input's own sub-code, once, carrying the last
/// known value. Three things are asserted together because each is a separate way the old
/// behaviour was wrong: no evaluation (no NRE storm), the sub-code propagated (not a generic
/// BadInternalError, which would blame the script), and no script-log entry (an input
/// dying is not a script fault).
///
[Fact]
public async Task A_bad_input_holds_evaluation_and_publishes_the_inputs_own_sub_code_once()
{
var (driver, published, logs) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
bad.Snapshot.StatusCode.ShouldNotBe(StatusBadInternalError, "the script did not fail — its input did");
bad.Snapshot.Value.ShouldBe(30.0, "the last known value rides out with the Bad status");
logs.ShouldBeEmpty("a dead input is not a script fault; the script log stays the per-failure diagnostic");
// Transition-once: a second Bad push of the same code changes nothing observable.
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty();
}
///
/// A different Bad sub-code IS republished: the sub-code is the diagnostic, and masking
/// BadNoCommunication behind an earlier BadWaitingForInitialData (or vice versa)
/// hides which input died and why.
///
[Fact]
public async Task A_different_bad_sub_code_is_republished()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out _).ShouldBeTrue();
driver.OnDependencyValue("src/a", null, StatusBadInternalError, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var second).ShouldBeTrue();
second!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
}
///
/// The vacuity-sensitive assertion. The input recovers with the value it had before the
/// outage, so the computed output is identical to the pre-outage one — and it must still be
/// republished Good, because the node is currently Bad and only a publish can clear it. A
/// value-only dedup swallows exactly this case, which is why a recovery test with a changed value
/// is not the guard.
///
[Fact]
public async Task An_identical_value_is_republished_when_recovering_from_a_bad_input()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out _).ShouldBeTrue();
// The device comes back holding the same reading it had before.
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var recovered).ShouldBeTrue();
recovered!.Snapshot.Value.ShouldBe(30.0, "the same value as before the outage — that is the point");
recovered.Snapshot.StatusCode.ShouldBe(StatusGood);
}
///
/// An Uncertain input is still computable, so the script runs and the output inherits the
/// Uncertain code — the alarm engine's rule (Uncertain is "ready"). Holding here would take a
/// perfectly good arithmetic result hostage.
///
[Fact]
public async Task An_uncertain_input_is_evaluated_and_stamps_the_output_uncertain()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", 11.0, StatusUncertain, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var uncertain).ShouldBeTrue();
uncertain!.Snapshot.Value.ShouldBe(31.0, "the script DID run — Uncertain is usable");
uncertain.Snapshot.StatusCode.ShouldBe(StatusUncertain);
// …and returning to Good republishes, even though the computed value changes back to a value the
// tag has held before.
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);
}
///
/// Cold start is unchanged: with one dep never pushed, a Bad arrival on the other publishes
/// nothing at all (the node stays at the materialiser's BadWaitingForInitialData). Only once
/// every dep has arrived does a Bad among them become reportable.
///
[Fact]
public async Task Nothing_is_published_while_a_dep_has_never_arrived()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty("src/b has never arrived — this is warm-up, not a fault");
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
}
///
/// A passthrough script over a Bad input must not publish Ok(null) Good — the worst case of
/// the laundering, since the passthrough fast path bypasses the read cache entirely.
///
[Fact]
public async Task A_passthrough_over_a_bad_input_is_bad_not_good_null()
{
var (driver, published, _) = Build(CalcTag("calc/e/pass", "return ctx.GetTag(\"src/a\").Value;"));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 42.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.Value.ShouldBe(42.0);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
bad.Snapshot.StatusCode.ShouldNotBe(StatusGood);
}
///
/// ReadAsync reports what the subscriber was told. A read that answered Good while the
/// subscription carried Bad would leave two clients of the same node disagreeing about it.
///
[Fact]
public async Task ReadAsync_reports_the_same_status_the_subscriber_saw()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var bad).ShouldBeTrue();
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(bad!.Snapshot.StatusCode);
read[0].Value.ShouldBe(bad.Snapshot.Value);
}
///
/// A tag whose ONLY publish was inputs-Bad — it never produced a value — must read back that Bad
/// status, not BadWaitingForInitialData. Guarding the read on "has a value" rather than
/// "has published" made a polled read say "never polled" while the subscriber on the same node had
/// been told BadNoCommunication: the two clients disagree, and the read is the one that
/// lies (something HAS been observed about this tag — that its input is dead).
///
[Fact]
public async Task ReadAsync_after_an_inputs_Bad_with_no_prior_value_reports_the_bad_status()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
// The first thing that ever happens to this tag is a dead input — no successful evaluation, so
// there is no last value to carry.
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
bad.Snapshot.Value.ShouldBeNull();
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(StatusBadNoCommunication);
read[0].StatusCode.ShouldNotBe(StatusBadWaitingForInitialData,
"something HAS been observed about this tag — that its input is dead");
read[0].Value.ShouldBeNull();
}
/// A never-evaluated tag still reads BadWaitingForInitialData (regression).
[Fact]
public async Task An_unevaluated_tag_still_reads_BadWaitingForInitialData()
{
var (driver, _, _) = Build(CalcTag(SumTag, SumScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
}
}