using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
///
/// Coverage for the two signals that carry no item payload and so
/// have no natural home in the data-change fan-out: the gateway's replay-gap sentinel
/// (MxGateway 0.2.0) and a stream the gateway completes without an error.
///
///
///
/// ⚠️ The replay-gap sentinel is unreachable in the shipped configuration.
/// GatewayGalaxySubscriber opens the stream with after_worker_sequence = 0,
/// which the gateway treats as "fresh stream, not a resume — plain attach, no replay, no
/// sentinel". These tests therefore pin a contract obligation rather than a live path: the
/// sentinel carries family = UNSPECIFIED, so before this work it fell straight
/// through the pump's family switch into the default drop, and enabling resume later would
/// have silently ignored a documented MUST.
///
///
/// The clean-completion half, by contrast, IS live: a gateway that closes or evicts a
/// session ends the RPC normally, which raises nothing at all.
///
///
public sealed class EventPumpReplayGapTests
{
private const int WaitMs = 2_000;
private static MxEvent GapSentinel(ulong requestedAfter, ulong oldestAvailable) => new()
{
// Shaped exactly as the gateway emits it: no family, no body, no per-item fields.
Family = MxEventFamily.Unspecified,
ReplayGap = new ReplayGap
{
RequestedAfterSequence = requestedAfter,
OldestAvailableSequence = oldestAvailable,
},
};
/// Verifies the sentinel reaches the callback carrying both sequence bounds.
[Fact]
public async Task A_replay_gap_sentinel_invokes_the_callback_with_both_sequences()
{
var subscriber = new ControllableSubscriber();
var observed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, new SubscriptionRegistry(), channelCapacity: 8, clientName: "GapTest",
onReplayGap: gap => observed.TrySetResult(gap));
pump.Start();
await subscriber.EmitAsync(GapSentinel(requestedAfter: 100, oldestAvailable: 250));
(await Task.WhenAny(observed.Task, Task.Delay(WaitMs))).ShouldBe(observed.Task,
"a replay-gap sentinel must reach the driver so it can re-snapshot");
var gap = await observed.Task;
gap.RequestedAfterSequence.ShouldBe(100UL);
gap.OldestAvailableSequence.ShouldBe(250UL);
}
///
/// Verifies the sentinel is consumed as a control signal and leaves the stream working.
///
///
/// The "not dispatched" half is cheap insurance rather than the point: a sentinel carries
/// item handle 0, and never binds a non-positive handle
/// (that is its marker for a failed gateway subscribe), so it could not have reached a
/// subscriber even before it was intercepted. What this actually pins is that consuming
/// the sentinel off the read loop does not disturb the events around it — the load-bearing
/// assertion is the positive control that follows it.
///
[Fact]
public async Task A_replay_gap_sentinel_is_not_dispatched_as_a_data_change()
{
var subscriber = new ControllableSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
var dispatched = 0;
var gapSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var realValue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 8, clientName: "GapTest",
onReplayGap: _ => gapSeen.TrySetResult());
pump.OnDataChange += (_, args) => { Interlocked.Increment(ref dispatched); realValue.TrySetResult(args); };
pump.Start();
await subscriber.EmitAsync(GapSentinel(requestedAfter: 1, oldestAvailable: 2));
(await Task.WhenAny(gapSeen.Task, Task.Delay(WaitMs))).ShouldBe(gapSeen.Task);
// A positive control: follow the sentinel with a real event on the same handle. Without
// it, "nothing was dispatched" would also pass on a pump that dispatches nothing at all.
await subscriber.EmitAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = 7,
Value = new MxValue { DoubleValue = 12.5 },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
(await Task.WhenAny(realValue.Task, Task.Delay(WaitMs))).ShouldBe(realValue.Task,
"the pump must keep dispatching real events after a sentinel");
(await realValue.Task).Snapshot.Value.ShouldBe(12.5);
Volatile.Read(ref dispatched).ShouldBe(1, "only the real event may be dispatched, never the sentinel");
}
///
/// Verifies the sentinel survives a full event channel. This is the reason it is handled
/// on the read loop rather than in the dispatch loop: everything that goes through the
/// channel is subject to newest-dropped pressure, and "you missed events" is the one
/// signal whose loss cannot be recovered from later — the pump would carry on serving
/// stale values with no indication anything was wrong.
///
[Fact]
public async Task A_replay_gap_sentinel_survives_a_full_event_channel()
{
var subscriber = new ControllableSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
using var blockDispatch = new ManualResetEventSlim(false);
var observed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 1, clientName: "GapTest",
onReplayGap: gap => observed.TrySetResult(gap));
// Wedge the dispatch loop so the bounded channel fills and stays full.
pump.OnDataChange += (_, _) => blockDispatch.Wait(TimeSpan.FromSeconds(10));
pump.Start();
for (var i = 0; i < 20; i++)
{
await subscriber.EmitAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = 7,
Value = new MxValue { DoubleValue = i },
Quality = 192,
});
}
await subscriber.EmitAsync(GapSentinel(requestedAfter: 5, oldestAvailable: 900));
(await Task.WhenAny(observed.Task, Task.Delay(WaitMs))).ShouldBe(observed.Task,
"the sentinel must not be droppable by channel back-pressure");
(await observed.Task).OldestAvailableSequence.ShouldBe(900UL);
blockDispatch.Set();
}
///
/// Verifies a throwing gap callback cannot take the stream down with it. The callback
/// runs on the read loop, so an escaping exception would land in RunAsync's catch and be
/// reported as a transport fault — tearing down a stream that is perfectly healthy.
///
[Fact]
public async Task A_throwing_replay_gap_callback_does_not_fault_the_stream()
{
var subscriber = new ControllableSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
var faulted = false;
var resumed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 8, clientName: "GapTest",
onStreamFault: _ => faulted = true,
onReplayGap: _ => throw new InvalidOperationException("re-snapshot could not be scheduled"));
pump.OnDataChange += (_, args) => resumed.TrySetResult(args);
pump.Start();
await subscriber.EmitAsync(GapSentinel(requestedAfter: 1, oldestAvailable: 2));
await subscriber.EmitAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = 7,
Value = new MxValue { DoubleValue = 99.0 },
Quality = 192,
});
(await Task.WhenAny(resumed.Task, Task.Delay(WaitMs))).ShouldBe(resumed.Task,
"a failed re-snapshot must not stop the stream that is still delivering");
faulted.ShouldBeFalse("a throwing gap callback is not a transport fault");
}
///
/// Verifies a stream the GATEWAY completes is reported as a fault. A closed or evicted
/// session ends the RPC normally, so nothing throws: before this, the pump exited
/// silently, the supervisor was never told, and GetHealth() kept reporting
/// Healthy — which also means the #527 comms-loss watchdog never tripped, since it
/// requires a non-Healthy state. The driver went permanently deaf with no signal at all.
///
///
/// The falsification control for this lives in
/// : our
/// own disposal also ends the stream, and must NOT be reported.
///
[Fact]
public async Task A_stream_the_gateway_completes_without_an_error_is_reported_as_a_fault()
{
var subscriber = new ControllableSubscriber();
var faultObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, new SubscriptionRegistry(), channelCapacity: 4, clientName: "GapTest",
onStreamFault: ex => faultObserved.TrySetResult(ex));
pump.Start();
subscriber.CompleteStream();
(await Task.WhenAny(faultObserved.Task, Task.Delay(WaitMs))).ShouldBe(faultObserved.Task,
"a server-completed stream is as fatal as a faulted one and must reach the supervisor");
(await faultObserved.Task).Message.ShouldContain("closed or evicted");
}
///
/// Verifies a value kind this driver cannot represent is published as Bad rather
/// than as a null carrying the event's own quality.
///
///
/// The event below carries Quality = 192 (OPC DA Good), so a decoder that gave up
/// silently would publish null under a Good 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.
///
[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();
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");
}
}
///
/// fake whose stream can be fed arbitrary events and
/// completed cleanly, so both control-signal paths can be driven directly.
///
private sealed class ControllableSubscriber : IGalaxySubscriber
{
private readonly Channel _stream =
Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true });
/// Subscribes to multiple tags (test stub).
/// The tag references to subscribe to.
/// The buffered update interval in milliseconds.
/// The cancellation token.
public Task> SubscribeBulkAsync(
IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult>([]);
/// Unsubscribes from multiple tags (test stub).
/// The item handles to unsubscribe from.
/// The cancellation token.
public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// Streams events asynchronously (test stub).
/// The cancellation token.
public IAsyncEnumerable StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// Pushes one event onto the stream.
/// The event to emit.
public ValueTask EmitAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
/// Ends the stream the way a gateway closing the session does — with no error.
public void CompleteStream() => _stream.Writer.TryComplete();
}
}