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(); } }