fix(calculation): hold evaluation on Bad inputs and propagate the worst input status; status-keyed dedup (05/STAB-19)

This commit is contained in:
Joseph Doherty
2026-08-20 10:37:29 -04:00
parent 3f8d5815b3
commit 48efdac600
4 changed files with 397 additions and 21 deletions
@@ -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.
@@ -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"
@@ -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<string, object?> _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<string, DataValueSnapshot> _depValues = new(StringComparer.Ordinal);
private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
private readonly Dictionary<string, TagState> _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 <see cref="_lock"/> by
/// <see cref="PrepareEvaluation"/> and executed outside it, so the script itself needs no shared state.</summary>
/// <param name="Definition">The calc tag being evaluated.</param>
/// <param name="Dependencies">Its declared reads' values, as of the trigger.</param>
/// <param name="Dependencies">Its declared reads' snapshots — value, status and timestamps — as of the trigger.</param>
/// <param name="TimestampUtc">The trigger's timestamp, stamped on whatever is published.</param>
/// <param name="WorstInputStatus">The worst status among <paramref name="Dependencies"/>
/// (<see cref="InputQuality.WorstOf"/>). Bad ⇒ the script is HELD and this status is published;
/// Good or Uncertain ⇒ the script runs and its result is stamped with it.</param>
private sealed record PendingEvaluation(
CalculationTagDefinition Definition,
IReadOnlyDictionary<string, object?> Dependencies,
DateTime TimestampUtc);
IReadOnlyDictionary<string, DataValueSnapshot> Dependencies,
DateTime TimestampUtc,
uint WorstInputStatus);
/// <summary>
/// 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<string, object?>(StringComparer.Ordinal);
var deps = new Dictionary<string, DataValueSnapshot>(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);
}
/// <summary>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
}
/// <summary>Record a successful evaluation. MUST be called under <see cref="_lock"/>; 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).</summary>
/// publish, or null when the result is deduped.
/// <para><b>The dedup key is (value, status), not value alone</b> — 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.</para></summary>
/// <param name="st">The tag's published state.</param>
/// <param name="value">The computed value.</param>
/// <param name="statusCode">The status to stamp: the worst input status (Good, or an Uncertain sub-code).</param>
/// <param name="timestampUtc">The trigger's timestamp.</param>
/// <returns>The snapshot to publish, or null when deduped.</returns>
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);
}
/// <summary>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 — <b>once per change of that status</b>: a
/// repeat of the same Bad is silent, while <c>BadNoCommunication</c> arriving over an earlier
/// <c>BadInternalError</c> (or the reverse) is not masked, because the sub-code is what names which
/// failure the operator is looking at. MUST be called under <see cref="_lock"/>.</summary>
/// <param name="st">The tag's published state.</param>
/// <param name="worstInputStatus">The worst status among the tag's declared inputs.</param>
/// <param name="timestampUtc">The trigger's timestamp.</param>
/// <returns>The snapshot to publish, or null when that status was already published.</returns>
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);
}
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): the caller always emits the
@@ -568,9 +634,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <returns>The Bad snapshot to publish on a fresh Good→Bad transition, else null.</returns>
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;
/// <summary>Whether anything has been published for this tag yet. Distinct from
/// <see cref="HasValue"/>: an inputs-Bad publish carries no value of its own.</summary>
public bool HasPublished;
/// <summary>The status code last published for this tag. Replaces the old two-state
/// <c>LastPublishedBad</c> 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 <c>BadInternalError</c>.</summary>
public uint LastPublishedStatus;
public DateTime LastTimestampUtc;
}
@@ -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;
/// <summary>
/// Archreview <b>05/STAB-19</b> — what the Calculation driver does with a dependency's <b>quality</b>.
///
/// <para><b>The defect.</b> The driver stored dependency <i>values</i> only and ignored the status code
/// entirely, so a comms-lost input arrived as <em>arrived, Good, null</em>: the warm-up gate was
/// satisfied by a value that never came, <c>(double)ctx.GetTag("x").Value</c> threw on every trigger
/// (a "script threw" storm), and a null-tolerant script published a <b>Good</b> output computed from a
/// dead input — indefinitely.</para>
///
/// <para><b>The policy pinned here</b> matches <c>ScriptedAlarmEngine</c>'s, which is the in-tree
/// precedent: <b>hold on Bad, propagate the worst input's status; evaluate through Uncertain and stamp
/// the output with it.</b> 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.</para>
///
/// <para>⚠️ <b>Vacuity rule:</b> the recovery case re-publishes an <b>identical</b> 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.</para>
/// </summary>
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<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
Build(params RawTagEntry[] tags)
{
var logs = new List<ScriptLogEntry>();
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<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => published.Enqueue(e);
return (driver, published, logs);
}
/// <summary>Local copy of the script-log capture sink (the sibling suite's is private to it).</summary>
private sealed class CapturingPublisher(List<ScriptLogEntry> 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");
}
/// <summary>Drives both deps Good and drains the resulting publish, leaving a settled Good output.</summary>
private static async Task<CalculationDriver> WarmedUpAsync(ConcurrentQueue<DataChangeEventArgs> 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;
}
/// <summary>
/// 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
/// <c>BadInternalError</c>, which would blame the script), and no script-log entry (an input
/// dying is not a script fault).
/// </summary>
[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();
}
/// <summary>
/// A different Bad sub-code IS republished: the sub-code is the diagnostic, and masking
/// <c>BadNoCommunication</c> behind an earlier <c>BadWaitingForInitialData</c> (or vice versa)
/// hides which input died and why.
/// </summary>
[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);
}
/// <summary>
/// <b>The vacuity-sensitive assertion.</b> The input recovers with the value it had before the
/// outage, so the computed output is <b>identical</b> 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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 <c>BadWaitingForInitialData</c>). Only once
/// every dep has arrived does a Bad among them become reportable.
/// </summary>
[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);
}
/// <summary>
/// A passthrough script over a Bad input must not publish <c>Ok(null)</c> Good — the worst case of
/// the laundering, since the passthrough fast path bypasses the read cache entirely.
/// </summary>
[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);
}
/// <summary>
/// <c>ReadAsync</c> 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.
/// </summary>
[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);
}
/// <summary>A never-evaluated tag still reads BadWaitingForInitialData (regression).</summary>
[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);
}
}