using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
///
/// The StreamEvents resume cursor kept by (ScadaBridge#128).
/// The gateway emits control sentinels with worker_sequence 0 — the ReplayGap sentinel
/// on a resume that hit evicted events and (opt-in only) the StreamAttached ack. Neither is a
/// worker event, so neither may move the cursor; a ReplayGap instead anchors it at
/// oldest_available_sequence - 1, the gateway's documented resume formula.
///
public class RealMxGatewayClientResumeCursorTests
{
private static MxEvent DataChange(ulong seq, int itemHandle = 999) => new()
{
Family = MxEventFamily.OnDataChange,
WorkerSequence = seq,
ItemHandle = itemHandle,
};
private static MxEvent WriteComplete(ulong seq) => new()
{
Family = MxEventFamily.OnWriteComplete,
WorkerSequence = seq,
};
/// The gateway's ReplayGap sentinel: family UNSPECIFIED, worker_sequence 0.
private static MxEvent ReplayGapSentinel(ulong requestedAfter, ulong oldestAvailable) => new()
{
Family = MxEventFamily.Unspecified,
WorkerSequence = 0,
ReplayGap = new ReplayGap
{
RequestedAfterSequence = requestedAfter,
OldestAvailableSequence = oldestAvailable,
},
};
///
/// The StreamAttached ack as a 0.2.1-contract client decodes it: the
/// stream_attached field is unknown to that contract, so all that is visible is a
/// family-UNSPECIFIED event with worker_sequence 0.
///
private static MxEvent StreamAttachedSentinel() => new()
{
Family = MxEventFamily.Unspecified,
WorkerSequence = 0,
};
private static async IAsyncEnumerable Stream(params MxEvent[] events)
{
foreach (var ev in events)
{
await Task.Yield();
yield return ev;
}
}
private static Task Run(RealMxGatewayClient client, params MxEvent[] events) =>
client.RunEventLoopCoreAsync(Stream(events), _ => { }, CancellationToken.None);
/// Real worker events of every family advance the cursor to the last sequence seen.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RealEvents_AdvanceCursor()
{
var client = new RealMxGatewayClient(null);
await Run(client, DataChange(1), WriteComplete(2), DataChange(3));
Assert.Equal(3UL, client.ResumeCursor);
}
///
/// The issue's case: a resume that hits a gap with nothing retained (age eviction or a
/// disabled ring) yields ONLY the ReplayGap sentinel. Resetting the cursor to 0 would turn
/// the next reconnect into a fresh stream — no replay and no gap signal.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task ReplayGapSentinel_AloneOnStream_AnchorsCursorAtOldestMinusOne()
{
var client = new RealMxGatewayClient(null);
await Run(client, DataChange(9), DataChange(10));
// Ring empty: the gateway reports the next deliverable sequence (highest observed + 1).
await Run(client, ReplayGapSentinel(requestedAfter: 10, oldestAvailable: 100));
Assert.Equal(99UL, client.ResumeCursor);
}
/// A gap followed by the retained replay batch ends on the last replayed sequence.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ReplayGapSentinel_FollowedByReplay_CursorEndsOnLastReplayedEvent()
{
var client = new RealMxGatewayClient(null);
await Run(client, DataChange(10));
await Run(client, ReplayGapSentinel(10, 50), DataChange(50), DataChange(51));
Assert.Equal(51UL, client.ResumeCursor);
}
/// The (opt-in) StreamAttached ack carries worker_sequence 0 and must not move the cursor.
/// A task that represents the asynchronous operation.
[Fact]
public async Task StreamAttachedSentinel_DoesNotResetCursor()
{
var client = new RealMxGatewayClient(null);
await Run(client, DataChange(7));
await Run(client, StreamAttachedSentinel());
Assert.Equal(7UL, client.ResumeCursor);
}
/// The cursor only ever advances: a lower sequence never moves it back.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Cursor_NeverMovesBackwards()
{
var client = new RealMxGatewayClient(null);
await Run(client, DataChange(20), DataChange(5), ReplayGapSentinel(1, 3));
Assert.Equal(20UL, client.ResumeCursor);
}
///
/// Events in the evicted interval are unrecoverable; the loss is surfaced as a warning
/// naming the interval, never hidden.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task ReplayGapSentinel_LogsWarningNamingTheLostInterval()
{
var provider = new CapturingLoggerProvider();
using var factory = LoggerFactory.Create(b => b.AddProvider(provider));
var client = new RealMxGatewayClient(factory);
await Run(client, ReplayGapSentinel(10, 50));
var warning = Assert.Single(provider.Entries, e => e.Level == LogLevel.Warning);
Assert.Contains("10", warning.Message);
Assert.Contains("50", warning.Message);
}
/// Sentinels never reach the value callback.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Sentinels_AreNotDeliveredAsValueUpdates()
{
var client = new RealMxGatewayClient(null);
var updates = 0;
await client.RunEventLoopCoreAsync(
Stream(StreamAttachedSentinel(), ReplayGapSentinel(1, 5)), _ => updates++, CancellationToken.None);
Assert.Equal(0, updates);
}
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new Capturing(this);
public void Dispose()
{
}
private sealed class Capturing(CapturingLoggerProvider owner) : ILogger
{
public IDisposable? BeginScope(TState state)
where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func formatter)
{
lock (owner.Entries)
owner.Entries.Add((logLevel, formatter(state, exception)));
}
}
}
}