fix(runtime): mux adapter forwards the dependency's real status code (05/STAB-19)

This commit is contained in:
Joseph Doherty
2026-08-20 10:30:14 -04:00
parent ae97b32c82
commit 3f8d5815b3
4 changed files with 117 additions and 3 deletions
@@ -731,3 +731,13 @@ test could only pass once the production change landed.
- The identical-value rule is honoured: the driver pushes **5.0 Good** before and after the outage, so the
recovery assertion cannot be satisfied by a value change. The control (`InitialPush = null`) asserts
`ExpectNoMsg` — no Good ever arrives — and that the audit fires with exactly 1 latched ref.
### T12 — `DependencyConsumerMuxAdapter` forwards the real status code
- RED: `--filter "…DependencyConsumerMuxAdapterTests"` ⇒ **2 failed**, both
`Shouldly.ShouldAssertException : consumer.Received[…].StatusCode should be 2150694912u` (0x80310000)
— the consumer received `0` (Good) for a `BadNoCommunication` input, on both the single and the batch
path. That is the finding, reproduced at the seam.
- GREEN: 11 passed (`DependencyConsumerMuxAdapterTests` + `DependencyMuxActorTests`).
- The false "quality is stripped at the mux boundary" comment is deleted and replaced with why it was
false (theme 6: the contract moved at #478/#519 and the comment outlived the fact).
@@ -87,7 +87,7 @@
{
"id": "T12",
"subject": "DependencyConsumerMuxAdapter forwards the real StatusCode; delete the false 'quality is stripped' comment (05/STAB-19)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
@@ -66,10 +66,23 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor
_log.Debug("DependencyConsumerMuxAdapter: registered {Count} dependency ref(s)", refs.Count);
}
/// <summary>
/// Forwards one dependency value to the consumer, <b>status code included</b> (archreview
/// 05/STAB-19).
/// </summary>
/// <remarks>
/// This used to pass a hard-coded <c>0u</c> behind a comment saying quality was stripped at the
/// mux boundary. That was true when the adapter was written and stopped being true at #478/#519 —
/// <see cref="VirtualTagActor.DependencyValueChanged"/> has carried the source driver's full status
/// code, sub-code and all, ever since. The stale comment outliving the fact is what kept a
/// comms-lost input arriving at the Calculation driver as <i>arrived, Good, null</i>:
/// indistinguishable from a genuine Good null, so the warm-up gate was satisfied by a value that
/// never arrived and the calc output stayed Good over a dead source.
/// </remarks>
/// <param name="msg">The dependency value, as the mux delivered it.</param>
private void OnDependencyValueChanged(VirtualTagActor.DependencyValueChanged msg)
{
// Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0).
_consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc);
_consumer.OnDependencyValue(msg.TagId, msg.Value, msg.StatusCode, msg.TimestampUtc);
}
/// <summary>The mux's batched delivery: one message carrying this adapter's whole matched slice of a
@@ -0,0 +1,91 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// Archreview <b>05/STAB-19</b> — the adapter that carries dependency values from the per-node
/// dependency mux into an <see cref="IDependencyConsumer"/> (today: the Calculation driver).
///
/// <para><b>The defect these pin.</b> The adapter forwarded a hard-coded <c>statusCode: 0u</c> behind
/// a comment claiming "quality is stripped at the mux boundary". That was true once; since #478/#519
/// <see cref="VirtualTagActor.DependencyValueChanged"/> carries the source driver's full status code,
/// sub-code included. The consequence was that a comms-lost input reached the Calculation driver as
/// <em>arrived, Good, null</em> — indistinguishable from a real Good reading of null, which is how a
/// calc output kept publishing Good over a dead input.</para>
/// </summary>
public sealed class DependencyConsumerMuxAdapterTests : RuntimeActorTestBase
{
private const string TagA = "Plant/Modbus/dev1/a";
private const string TagB = "Plant/Modbus/dev1/b";
/// <summary>Single delivery: the status code reaches the consumer verbatim, sub-code and all.</summary>
[Fact]
public void A_single_dependency_value_carries_its_status_code_to_the_consumer()
{
var (consumer, adapter) = Spawn();
adapter.Tell(new VirtualTagActor.DependencyValueChanged(
TagA, Value: null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
AwaitCondition(() => consumer.Received.Count == 1, PresenceBudget);
consumer.Received[0].Ref.ShouldBe(TagA);
consumer.Received[0].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
consumer.Received[0].StatusCode.ShouldNotBe(OpcUaStatus.Good,
"a Bad input forwarded as Good is the laundering 05/STAB-19 is about");
}
/// <summary>
/// Batched delivery goes through the same seam, so it must carry status too — the batch path is
/// what a real driver burst uses, and a fix applied only to the single-value path would be inert
/// in production.
/// </summary>
[Fact]
public void A_batched_delivery_carries_each_items_status_code()
{
var (consumer, adapter) = Spawn();
var ts = DateTime.UtcNow;
adapter.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged(TagA, 1.0, ts, OpcUaStatus.Good),
new VirtualTagActor.DependencyValueChanged(TagB, null, ts, OpcUaStatus.BadNoCommunication),
}));
AwaitCondition(() => consumer.Received.Count == 2, PresenceBudget);
consumer.Received[0].StatusCode.ShouldBe(OpcUaStatus.Good);
consumer.Received[1].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
}
private (RecordingConsumer Consumer, IActorRef Adapter) Spawn()
{
var consumer = new RecordingConsumer();
var mux = CreateTestProbe();
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(consumer, mux.Ref));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(PresenceBudget);
return (consumer, adapter);
}
private sealed class RecordingConsumer : IDependencyConsumer
{
private readonly List<(string Ref, object? Value, uint StatusCode)> _received = new();
private readonly object _gate = new();
public IReadOnlyList<(string Ref, object? Value, uint StatusCode)> Received
{
get { lock (_gate) return _received.ToArray(); }
}
public IReadOnlyCollection<string> DependencyRefs { get; } = [TagA, TagB];
public void OnDependencyValue(string fullReference, object? value, uint statusCode, DateTime timestampUtc)
{
lock (_gate) _received.Add((fullReference, value, statusCode));
}
}
}