From 987459fab305ea8843179305435dce22eb5915db Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:53:59 -0400 Subject: [PATCH] =?UTF-8?q?test(runtime):=20pin=20VT=20Bad-quality=20trans?= =?UTF-8?q?ition=20semantics=20=E2=80=94=20once-per-transition,=20same-val?= =?UTF-8?q?ue=20recovery,=20throw=20path=20(02/S13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2-03-vt-failure-quality-plan.md.tasks.json | 2 +- .../VirtualTags/VirtualTagActorTests.cs | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) 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 d0abbad6..1ec95919 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 @@ -26,7 +26,7 @@ { "id": "R2-03-T4", "subject": "S13 transition-semantics unit guards: second consecutive failure publishes nothing; same-value recovery republishes Good; evaluator-threw path also degrades", - "status": "pending", + "status": "completed", "blockedBy": [ "R2-03-T3" ] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs index 80ca9929..1a9913ce 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs @@ -87,6 +87,96 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad); } + /// 02/S13 once-per-transition: two consecutive failures publish exactly ONE Bad + /// EvaluationResult — the second failure is suppressed while already Bad. + [Fact] + public void Second_consecutive_failure_does_not_publish_a_second_Bad() + { + var parent = CreateTestProbe(); + var evaluator = new QueuedEvaluator( + VirtualTagEvalResult.Failure("boom1"), VirtualTagEvalResult.Failure("boom2")); + var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator)); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); + parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow)); + parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// 02/S13 same-value recovery: Ok(42) → fail → Ok(42) must republish Good with 42 even + /// though the value equals the pre-failure value (the dedup bypass — else the node stays Bad). + [Fact] + public void Recovery_to_the_same_value_republishes_Good_after_Bad() + { + var parent = CreateTestProbe(); + var evaluator = new QueuedEvaluator( + VirtualTagEvalResult.Ok(42), VirtualTagEvalResult.Failure("boom"), VirtualTagEvalResult.Ok(42)); + var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator)); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); + var first = parent.ExpectMsg(); + first.Quality.ShouldBe(OpcUaQuality.Good); + first.Value.ShouldBe(42); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow)); + parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 3, DateTime.UtcNow)); + var recovered = parent.ExpectMsg(); + recovered.Quality.ShouldBe(OpcUaQuality.Good); + recovered.Value.ShouldBe(42); + } + + /// 02/S13: an evaluator that THROWS (not just returns Failure) also degrades quality — + /// the try/catch path publishes a Bad EvaluationResult too. + [Fact] + public void Evaluator_throw_also_degrades_quality() + { + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(VirtualTagActor.Props( + "vt-1", "expr", evaluator: new ThrowingEvaluator())); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); + + parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad); + } + + /// Test evaluator returning a fixed sequence of results, one per Evaluate call; the last + /// result repeats once the queue is drained. + private sealed class QueuedEvaluator : IVirtualTagEvaluator + { + private readonly Queue _results; + private VirtualTagEvalResult _last; + /// Initializes the evaluator with an ordered sequence of results. + /// The results returned in order, one per call. + public QueuedEvaluator(params VirtualTagEvalResult[] results) + { + _results = new Queue(results); + _last = results[^1]; + } + /// Returns the next queued result (or the last one once drained). + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// The next queued result. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + => _results.Count > 0 ? _last = _results.Dequeue() : _last; + } + + /// Test evaluator that throws on every call — exercises the actor's try/catch degradation + /// path (distinct from a returned Failure). + private sealed class ThrowingEvaluator : IVirtualTagEvaluator + { + /// Always throws. + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// Never returns; throws. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + => throw new InvalidOperationException("kaboom"); + } + /// Test evaluator that sums integer dependency values. private sealed class SumEvaluator : IVirtualTagEvaluator {