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); } }