test(runtime): pin VT Bad-quality transition semantics — once-per-transition, same-value recovery, throw path (02/S13)

This commit is contained in:
Joseph Doherty
2026-07-13 09:53:59 -04:00
parent 01253818de
commit 987459fab3
2 changed files with 91 additions and 1 deletions
@@ -26,7 +26,7 @@
{ {
"id": "R2-03-T4", "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", "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": [ "blockedBy": [
"R2-03-T3" "R2-03-T3"
] ]
@@ -87,6 +87,96 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad); parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
} }
/// <summary>02/S13 once-per-transition: two consecutive failures publish exactly ONE Bad
/// EvaluationResult — the second failure is suppressed while already Bad.</summary>
[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<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>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).</summary>
[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<VirtualTagActor.EvaluationResult>();
first.Quality.ShouldBe(OpcUaQuality.Good);
first.Value.ShouldBe(42);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 3, DateTime.UtcNow));
var recovered = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
recovered.Quality.ShouldBe(OpcUaQuality.Good);
recovered.Value.ShouldBe(42);
}
/// <summary>02/S13: an evaluator that THROWS (not just returns Failure) also degrades quality —
/// the try/catch path publishes a Bad EvaluationResult too.</summary>
[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<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
}
/// <summary>Test evaluator returning a fixed sequence of results, one per Evaluate call; the last
/// result repeats once the queue is drained.</summary>
private sealed class QueuedEvaluator : IVirtualTagEvaluator
{
private readonly Queue<VirtualTagEvalResult> _results;
private VirtualTagEvalResult _last;
/// <summary>Initializes the evaluator with an ordered sequence of results.</summary>
/// <param name="results">The results returned in order, one per call.</param>
public QueuedEvaluator(params VirtualTagEvalResult[] results)
{
_results = new Queue<VirtualTagEvalResult>(results);
_last = results[^1];
}
/// <summary>Returns the next queued result (or the last one once drained).</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>The next queued result.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> _results.Count > 0 ? _last = _results.Dequeue() : _last;
}
/// <summary>Test evaluator that throws on every call — exercises the actor's try/catch degradation
/// path (distinct from a returned Failure).</summary>
private sealed class ThrowingEvaluator : IVirtualTagEvaluator
{
/// <summary>Always throws.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>Never returns; throws.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> throw new InvalidOperationException("kaboom");
}
/// <summary>Test evaluator that sums integer dependency values.</summary> /// <summary>Test evaluator that sums integer dependency values.</summary>
private sealed class SumEvaluator : IVirtualTagEvaluator private sealed class SumEvaluator : IVirtualTagEvaluator
{ {