test(runtime): red repro — VT script failure after success leaves node stale-Good (02/S13)

This commit is contained in:
Joseph Doherty
2026-07-13 09:48:41 -04:00
parent 1676c8f40f
commit b977548593
2 changed files with 60 additions and 1 deletions
@@ -4,7 +4,7 @@
{ {
"id": "R2-03-T1", "id": "R2-03-T1",
"subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate", "subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
"status": "pending", "status": "completed",
"blockedBy": [] "blockedBy": []
}, },
{ {
@@ -365,6 +365,65 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
AwaitAssert(() => evaluator.ClearCount.ShouldBe(2)); AwaitAssert(() => evaluator.ClearCount.ShouldBe(2));
} }
/// <summary>
/// 02/S13 stale-Good repro (RED on current code): a script that succeeds once then starts failing
/// must degrade its node's quality to Bad — carrying the last-known value — rather than freezing on
/// the last Good value forever. Drives the REAL host + real child + parent-Tell bridge: apply one
/// plan (deps ["a"]), capture the spawned child via the mux probe's RegisterInterest sender, then
/// feed it two dependency changes. The first evaluates Ok(42) → a Good AttributeValueUpdate; the
/// second fails → the fix publishes a Bad AttributeValueUpdate carrying the stale 42. On the
/// unfixed tree the second publish never arrives (the second ExpectMsg times out).
/// </summary>
[Fact]
public void Failing_script_after_success_degrades_node_quality_to_Bad()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref,
new FailAfterFirstSuccessEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
// The child self-registers with the mux in PreStart — capture its ref from the sender so we can
// drive it directly through the real evaluate → parent-Tell → bridge path.
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
var child = mux.LastSender;
var ts1 = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
var ts2 = new DateTime(2026, 7, 12, 12, 0, 1, DateTimeKind.Utc);
// First dep change → evaluator Ok(42) → Good publish.
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, ts1));
var good = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
good.Quality.ShouldBe(OpcUaQuality.Good);
good.Value.ShouldBe(42.0);
// Second dep change → evaluator Failure → Bad publish carrying the last-known value 42.
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, ts2));
var bad = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
bad.Quality.ShouldBe(OpcUaQuality.Bad);
bad.Value.ShouldBe(42.0);
bad.TimestampUtc.ShouldBe(ts2);
}
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
/// evaluation thread.</summary>
private sealed class FailAfterFirstSuccessEvaluator : IVirtualTagEvaluator
{
private int _calls;
/// <summary>Returns Ok(42.0) once, then Failure("boom").</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>Ok(42.0) on the first call; Failure on every subsequent call.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> Interlocked.Increment(ref _calls) == 1
? VirtualTagEvalResult.Ok(42.0)
: VirtualTagEvalResult.Failure("boom");
}
/// <summary>Capturing <see cref="IHistoryWriter"/>: records every Record call so H5c tests can /// <summary>Capturing <see cref="IHistoryWriter"/>: records every Record call so H5c tests can
/// assert the host historizes (or does not) and with what path + snapshot.</summary> /// assert the host historizes (or does not) and with what path + snapshot.</summary>
private sealed class CapturingHistoryWriter : IHistoryWriter private sealed class CapturingHistoryWriter : IHistoryWriter