fix(galaxy): publish an unrepresentable value as BadTypeMismatch, not a good null
Reverses the conservative call from the previous commit at the user's direction, and they were right: "surface it on a counter first" left the wire lying. An MXAccess event can carry Good quality while its payload is a kind MxValueDecoder has no mapping for, so the old behaviour published null-under-Good — which a client cannot tell apart from a genuine null reading. A value the gateway DID send would be consumed as a value it did not, with the only evidence in a log nobody is tailing. Bad data is the honest wire representation of lost data. BadTypeMismatch is an existing StatusCodeMap constant, deliberately reused rather than written inline: StatusCodeParityTests finds status constants by reflecting over Good/Uncertain/Bad-prefixed uint fields and checking them against the pinned SDK, so an inline literal would be invisible to that guard (Gitea #497, where 16 wrong constants had accumulated across 6 drivers exactly this way). Logging is now once per DISTINCT value kind rather than once per pump. The set is bounded by the MxValue oneof enum so a broken producer still cannot flood, but a second unrepresentable kind appearing later gets its own line — warn-once-per-pump would have silently swallowed it, which is the same class of bug as the null this commit fixes. The test's fixture matters more than its assertion: the undecodable event carries Quality = 192 (OPC DA Good), so a decoder that gave up silently would publish Good and fail. Paired with a positive control — a decodable value at the same quality must still publish Good — so it cannot be satisfied by a pump that marks everything Bad. Falsified by reverting the status to Good: the test reds, and only that test. Verified: 331 Galaxy tests green, Core.Abstractions 298 (the parity guard), solution builds 0 errors. Docs corrected in the two places that recorded the old decision as deliberate.
This commit is contained in:
+69
@@ -222,6 +222,75 @@ public sealed class EventPumpReplayGapTests
|
||||
(await faultObserved.Task).Message.ShouldContain("closed or evicted");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a value kind this driver cannot represent is published as <c>Bad</c> rather
|
||||
/// than as a null carrying the event's own quality.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The event below carries <c>Quality = 192</c> (OPC DA Good), so a decoder that gave up
|
||||
/// silently would publish <c>null</c> under a <b>Good</b> status — indistinguishable from
|
||||
/// a genuine null reading, which is how a value the gateway did send would be consumed as
|
||||
/// a value it didn't. The Good quality is the point of the fixture: with a Bad quality on
|
||||
/// the event the assertion would hold no matter what the decoder did.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task An_unrepresentable_value_is_published_as_bad_not_as_a_good_null()
|
||||
{
|
||||
var subscriber = new ControllableSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
|
||||
|
||||
var received = new List<DataChangeEventArgs>();
|
||||
var bothSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var pump = new EventPump(
|
||||
subscriber, registry, channelCapacity: 8, clientName: "GapTest");
|
||||
pump.OnDataChange += (_, args) =>
|
||||
{
|
||||
lock (received)
|
||||
{
|
||||
received.Add(args);
|
||||
if (received.Count == 2) bothSeen.TrySetResult();
|
||||
}
|
||||
};
|
||||
pump.Start();
|
||||
|
||||
// An MxValue with no kind set at all — a payload we cannot interpret — under Good quality.
|
||||
await subscriber.EmitAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = 7,
|
||||
Value = new MxValue(),
|
||||
Quality = 192,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
});
|
||||
|
||||
// Positive control: a decodable value on the same handle and the same Good quality must
|
||||
// still publish Good, so the assertion above cannot be satisfied by a pump that simply
|
||||
// marks everything Bad.
|
||||
await subscriber.EmitAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = 7,
|
||||
Value = new MxValue { DoubleValue = 42.0 },
|
||||
Quality = 192,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
});
|
||||
|
||||
(await Task.WhenAny(bothSeen.Task, Task.Delay(WaitMs))).ShouldBe(bothSeen.Task);
|
||||
|
||||
lock (received)
|
||||
{
|
||||
received[0].Snapshot.Value.ShouldBeNull();
|
||||
received[0].Snapshot.StatusCode.ShouldBe(StatusCodeMap.BadTypeMismatch,
|
||||
"an undecodable payload must not reach a client wearing the event's Good quality");
|
||||
|
||||
received[1].Snapshot.Value.ShouldBe(42.0);
|
||||
received[1].Snapshot.StatusCode.ShouldBe(StatusCodeMap.Good,
|
||||
"a decodable value on the same quality must still publish Good");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IGalaxySubscriber"/> fake whose stream can be fed arbitrary events and
|
||||
/// completed cleanly, so both control-signal paths can be driven directly.
|
||||
|
||||
Reference in New Issue
Block a user