diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json index 86bfffdd..91e07e35 100644 --- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json +++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json @@ -4,7 +4,7 @@ { "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", - "status": "pending", + "status": "completed", "blockedBy": [] }, { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index 9b1590e8..447db41e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -365,6 +365,65 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase AwaitAssert(() => evaluator.ClearCount.ShouldBe(2)); } + /// + /// 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). + /// + [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(); + 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(); + 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(); + bad.Quality.ShouldBe(OpcUaQuality.Bad); + bad.Value.ShouldBe(42.0); + bad.TimestampUtc.ShouldBe(ts2); + } + + /// Evaluator that returns Ok(42.0) on its first call and Failure thereafter — + /// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's + /// evaluation thread. + private sealed class FailAfterFirstSuccessEvaluator : IVirtualTagEvaluator + { + private int _calls; + + /// Returns Ok(42.0) once, then Failure("boom"). + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// Ok(42.0) on the first call; Failure on every subsequent call. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + => Interlocked.Increment(ref _calls) == 1 + ? VirtualTagEvalResult.Ok(42.0) + : VirtualTagEvalResult.Failure("boom"); + } + /// Capturing : records every Record call so H5c tests can /// assert the host historizes (or does not) and with what path + snapshot. private sealed class CapturingHistoryWriter : IHistoryWriter