diff --git a/archreview/plans/R3-02-stale-good-family-plan.md b/archreview/plans/R3-02-stale-good-family-plan.md index 2926347e..4c973b2e 100644 --- a/archreview/plans/R3-02-stale-good-family-plan.md +++ b/archreview/plans/R3-02-stale-good-family-plan.md @@ -741,3 +741,20 @@ test could only pass once the production change landed. - GREEN: 11 passed (`DependencyConsumerMuxAdapterTests` + `DependencyMuxActorTests`). - The false "quality is stripped at the mux boundary" comment is deleted and replaced with why it was false (theme 6: the contract moved at #478/#519 and the comment outlived the fact). + +### T13 — `CalculationDriver` input quality (hold on Bad, propagate worst, status-keyed dedup) + +- RED: `--filter "…CalculationDriverInputQualityTests"` ⇒ **5 failed**: `Snapshot.StatusCode should be + 2150694912u` (BadNoCommunication) on the Bad-input, cold-start and passthrough cases; `should be + 1073741824u` (Uncertain) on the Uncertain case; `published.Count should be 1` on the sub-code-change + case. That is the laundering, reproduced from three directions. +- GREEN: whole `Driver.Calculation.Tests` project 61/61 — the 9 new plus every pre-existing case + unchanged (the Good-only paths are byte-identical). +- **Vacuity check performed** (the plan's requirement): reverting the dedup key to value-only + (`if (st.HasValue && Equals(st.LastValue, value)) return null;`) ⇒ + `An_identical_value_is_republished_when_recovering_from_a_bad_input` **FAILED** with + `published.Count should be 1` — i.e. the recovery was swallowed, exactly the "Bad forever" trap. + Restored, green. +- `TagState.LastPublishedBad` (two-state) became `HasPublished` + `LastPublishedStatus` (uint), which is + what lets a Bad→different-Bad transition republish and what makes `ReadAsync` agree with the + subscriber. The evaluator is still handed bare values here — T14 hands it the snapshots. diff --git a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json index ac23153c..4d4a5ce4 100644 --- a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json +++ b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json @@ -93,7 +93,7 @@ { "id": "T13", "subject": "CalculationDriver input quality: snapshots per dep, hold on Bad, publish worst-of-input once per status change, status-keyed dedup bypass on recovery, ReadOne parity", - "status": "pending", + "status": "done", "blockedBy": [ "T1", "T12" diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs index 2363b539..a9704340 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs @@ -82,7 +82,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba // SHORT-HELD state lock — it is taken for dictionary work only and is NEVER held across a script run // (Driver.Calculation-005). private readonly object _lock = new(); - private readonly Dictionary _depValues = new(StringComparer.Ordinal); + // Dependency SNAPSHOTS, not bare values (archreview 05/STAB-19): the status code is what tells a + // comms-lost input apart from a genuine Good null, and storing values only is what let this driver + // compute a Good output from a dead source. + private readonly Dictionary _depValues = new(StringComparer.Ordinal); private readonly HashSet _arrivedDeps = new(StringComparer.Ordinal); private readonly Dictionary _stateByRawPath = new(StringComparer.Ordinal); @@ -332,7 +335,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba if (!_running) return; lock (_lock) { - _depValues[rawPath] = value; + _depValues[rawPath] = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc); _arrivedDeps.Add(rawPath); if (!_changeDependents.TryGetValue(rawPath, out var dependents) || dependents.Count == 0) return; @@ -393,7 +396,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba { if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue) return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now); - var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood; + // Exactly what the subscriber was last told — including a propagated input sub-code. Deriving + // it from a two-state flag here would make a read disagree with the subscription over the + // same node (05/STAB-19). + var status = st.LastPublishedStatus; return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc); } } @@ -404,12 +410,16 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba /// trigger fired, and that trigger's timestamp. Built under by /// and executed outside it, so the script itself needs no shared state. /// The calc tag being evaluated. - /// Its declared reads' values, as of the trigger. + /// Its declared reads' snapshots — value, status and timestamps — as of the trigger. /// The trigger's timestamp, stamped on whatever is published. + /// The worst status among + /// (). Bad ⇒ the script is HELD and this status is published; + /// Good or Uncertain ⇒ the script runs and its result is stamped with it. private sealed record PendingEvaluation( CalculationTagDefinition Definition, - IReadOnlyDictionary Dependencies, - DateTime TimestampUtc); + IReadOnlyDictionary Dependencies, + DateTime TimestampUtc, + uint WorstInputStatus); /// /// Prepare one calc tag's evaluation: resolve the definition, apply the change-gate, and snapshot @@ -441,10 +451,14 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba if (string.IsNullOrWhiteSpace(def.ScriptSource)) return null; // unresolved script — never computes if (!def.DependencyRefs.All(_arrivedDeps.Contains)) return null; // still warming up — do not evaluate - var deps = new Dictionary(StringComparer.Ordinal); + var deps = new Dictionary(StringComparer.Ordinal); foreach (var dep in def.DependencyRefs) if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v; - return new PendingEvaluation(def, deps, timestampUtc); + + // Worst-of-input, computed from the SAME snapshot the evaluation will use, so the status + // published can never disagree with the values it was derived from (05/STAB-19). + var worst = InputQuality.WorstOf(def.DependencyRefs.Select(r => deps.GetValueOrDefault(r))); + return new PendingEvaluation(def, deps, timestampUtc, worst); } /// Start the evaluation pump if it is not already running. MUST be called under @@ -513,7 +527,31 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba private void RunEvaluation(PendingEvaluation pending) { var def = pending.Definition; - var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, pending.Dependencies); + + // HOLD ON BAD (05/STAB-19, matching ScriptedAlarmEngine's AreInputsReady): a Bad input is not + // usable, so the script does NOT run — which removes the "(double)ctx.GetTag(x).Value threw" + // storm at its source rather than catching it downstream — and the tag reports the input's own + // sub-code. Uncertain IS usable: the script runs and the output inherits the Uncertain code. + if (!InputQuality.IsUsable(pending.WorstInputStatus)) + { + (object? Value, uint StatusCode)? badPublish; + lock (_lock) + { + if (!_stateByRawPath.TryGetValue(def.RawPath, out var badState)) return; + badPublish = MarkInputsBad(badState, pending.WorstInputStatus, pending.TimestampUtc); + } + + // No script log: an input dying is not a script fault, and the script log is the per-failure + // SCRIPT diagnostic. Here the status code is the diagnostic. + if (badPublish is { } bad) Publish(def.RawPath, bad.Value, bad.StatusCode, pending.TimestampUtc); + return; + } + + // Projected to bare values for now — the evaluator's own read cache still fabricates Good, which + // the next task fixes by handing it the snapshots. Only reachable with USABLE inputs (the Bad + // hold above returned), so nothing Bad is being flattened here. + var depValues = pending.Dependencies.ToDictionary(kv => kv.Key, kv => kv.Value.Value, StringComparer.Ordinal); + var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, depValues); string? failureReason = null; (object? Value, uint StatusCode)? toPublish; @@ -530,7 +568,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba } else { - toPublish = MarkEvaluationSucceeded(st, result.Value, pending.TimestampUtc); + toPublish = MarkEvaluationSucceeded(st, result.Value, pending.WorstInputStatus, pending.TimestampUtc); } } @@ -539,23 +577,51 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba } /// Record a successful evaluation. MUST be called under ; returns what to - /// publish, or null when the value is deduped. The dedup is bypassed while recovering from Bad so a - /// recovery whose value equals the pre-failure value still republishes Good (otherwise the node would - /// stay Bad forever). + /// publish, or null when the result is deduped. + /// The dedup key is (value, status), not value alone — 05/STAB-19 generalising the + /// Bad-recovery bypass 02/S13 introduced. Recovery is a publish, never an assumption: the node is + /// currently carrying the previous status, so a result whose VALUE is unchanged but whose STATUS + /// differs — the shape of every recovery, since a device that comes back usually comes back holding + /// the same reading — must still land, or the node stays Bad forever. /// The tag's published state. /// The computed value. + /// The status to stamp: the worst input status (Good, or an Uncertain sub-code). /// The trigger's timestamp. /// The snapshot to publish, or null when deduped. - private (object? Value, uint StatusCode)? MarkEvaluationSucceeded(TagState st, object? value, DateTime timestampUtc) + private (object? Value, uint StatusCode)? MarkEvaluationSucceeded( + TagState st, object? value, uint statusCode, DateTime timestampUtc) { - if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, value)) return null; + if (st.HasPublished && st.LastPublishedStatus == statusCode && st.HasValue && Equals(st.LastValue, value)) + return null; st.HasValue = true; st.LastValue = value; - st.LastPublishedBad = false; + st.HasPublished = true; + st.LastPublishedStatus = statusCode; st.LastTimestampUtc = timestampUtc; _lastComputeUtc = DateTime.UtcNow; - return (value, StatusGood); + return (value, statusCode); + } + + /// Record that this tag's INPUTS are Bad, so no evaluation ran (05/STAB-19). Publishes the + /// worst input's status — carrying the last known value — once per change of that status: a + /// repeat of the same Bad is silent, while BadNoCommunication arriving over an earlier + /// BadInternalError (or the reverse) is not masked, because the sub-code is what names which + /// failure the operator is looking at. MUST be called under . + /// The tag's published state. + /// The worst status among the tag's declared inputs. + /// The trigger's timestamp. + /// The snapshot to publish, or null when that status was already published. + private static (object? Value, uint StatusCode)? MarkInputsBad( + TagState st, uint worstInputStatus, DateTime timestampUtc) + { + if (st.HasPublished && st.LastPublishedStatus == worstInputStatus) return null; + + st.HasPublished = true; + st.LastPublishedStatus = worstInputStatus; + st.LastTimestampUtc = timestampUtc; + // Carry the last-known value (null if none) with the input's Bad status. + return (st.HasValue ? st.LastValue : null, worstInputStatus); } /// Failure tail (mirrors VirtualTagActor 02/S13): the caller always emits the @@ -568,9 +634,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba /// The Bad snapshot to publish on a fresh Good→Bad transition, else null. private static (object? Value, uint StatusCode)? MarkEvaluationFailed(TagState st, DateTime timestampUtc) { - if (st.LastPublishedBad) return null; + if (st.HasPublished && st.LastPublishedStatus == StatusBadInternalError) return null; - st.LastPublishedBad = true; + st.HasPublished = true; + st.LastPublishedStatus = StatusBadInternalError; st.LastTimestampUtc = timestampUtc; // Carry the last-known value (null if none) with Bad quality. return (st.HasValue ? st.LastValue : null, StatusBadInternalError); @@ -643,7 +710,15 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba { public bool HasValue; public object? LastValue; - public bool LastPublishedBad; + + /// Whether anything has been published for this tag yet. Distinct from + /// : an inputs-Bad publish carries no value of its own. + public bool HasPublished; + + /// The status code last published for this tag. Replaces the old two-state + /// LastPublishedBad flag (05/STAB-19): the dedup key is (value, status), and a Bad status + /// now carries the failing INPUT's sub-code rather than a fixed BadInternalError. + public uint LastPublishedStatus; public DateTime LastTimestampUtc; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverInputQualityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverInputQualityTests.cs new file mode 100644 index 00000000..05a48dfb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverInputQualityTests.cs @@ -0,0 +1,284 @@ +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 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); + } +}