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 {