using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #669 over a real SDK client session: an event HistoryRead capped below the available events pages /// through the PRODUCTION session-bound continuation store (SessionHistoryContinuationStore) — the /// path the session-less unit tests cannot reach — and the union of pages is every event exactly once. /// public sealed class EventHistoryPagingOverTheWireTests { private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); [Fact] public async Task A_capped_event_read_pages_through_the_session_store_exactly_once() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("EvtPagingWire", encrypted: false, ct); try { // Source installed BEFORE the alarm materialises so the folder is promoted with the HistoryRead bit. var events = Enumerable.Range(0, 25) .Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500)) .ToList(); // pairs share a timestamp, so page boundaries cut through ties rig.Nm.HistorianDataSource = new SqlLikeEventSource(events); rig.MaterialiseNative(); rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); var collected = new List(); byte[]? cp = null; var pages = 0; do { var (ids, next) = await ReadPageAsync(session, node, cp, ct); collected.AddRange(ids); cp = next; (++pages).ShouldBeLessThan(30, "paging must terminate"); } while (cp is not null); pages.ShouldBeGreaterThan(2, "25 events at a cap of 10 must page over the wire"); collected.ShouldBe(events.Select(e => e.EventId)); } finally { await rig.DisposeAsync(); } } [Fact] public async Task A_capped_REVERSE_event_read_pages_newest_first_through_the_session_store_exactly_once() { // Gitea #675: StartTime later than EndTime. Before the fix the cap kept the EARLIEST 10, ascending, with no // continuation point. var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("EvtPagingWireRev", encrypted: false, ct); try { var events = Enumerable.Range(0, 25) .Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500)) .ToList(); // pairs share a timestamp, so page boundaries cut through ties rig.Nm.HistorianDataSource = new SqlLikeEventSource(events); rig.MaterialiseNative(); rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); var collected = new List(); var sizes = new List(); byte[]? cp = null; do { var (ids, next) = await ReadPageAsync(session, node, cp, ct, reverse: true); collected.AddRange(ids); sizes.Add(ids.Count); cp = next; sizes.Count.ShouldBeLessThan(30, "paging must terminate"); } while (cp is not null); sizes.ShouldBe([10, 10, 5]); // Newest-first: time descending, ties by EventId descending. collected.ShouldBe(events.OrderByDescending(e => e.EventTimeUtc) .ThenByDescending(e => e.EventId, StringComparer.Ordinal).Select(e => e.EventId)); } finally { await rig.DisposeAsync(); } } private static async Task<(List Ids, byte[]? Cp)> ReadPageAsync( ISession session, NodeId node, byte[]? cp, CancellationToken ct, bool reverse = false) { var filter = new EventFilter(); filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value, }); var details = new ReadEventDetails { StartTime = reverse ? Epoch.AddDays(1) : Epoch.AddDays(-1), EndTime = reverse ? Epoch.AddDays(-1) : Epoch.AddDays(1), NumValuesPerNode = 10, Filter = filter, }; var response = await session.HistoryReadAsync( null, new ExtensionObject(details), TimestampsToReturn.Both, false, new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct); var result = response.Results.ShouldHaveSingleItem(); StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}"); var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData); var ids = he.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToList(); return (ids, result.ContinuationPoint is { Length: > 0 } c ? c : null); } /// (time, id) order, [start, end), cap + truncation flag — the SQL backends' contract. private sealed class SqlLikeEventSource(List events) : IHistorianDataSource { private readonly List _all = events.OrderBy(e => e.EventTimeUtc).ThenBy(e => e.EventId, StringComparer.Ordinal).ToList(); public async Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) { await Task.Delay(1, ct).ConfigureAwait(false); // complete on a pool thread, like a real backend var window = _all.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(); var cap = maxEvents <= 0 ? 1000 : maxEvents; return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null); } public bool SupportsReverseEvents => true; /// The #675 overload: reverse is (lo, hi] by (time, id) descending. public async Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct) { if (!reverse) return await ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct).ConfigureAwait(false); await Task.Delay(1, ct).ConfigureAwait(false); var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc); var window = _all.Where(e => e.EventTimeUtc > lo && e.EventTimeUtc <= hi).Reverse().ToList(); var cap = maxEvents <= 0 ? 1000 : maxEvents; return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null); } public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); public Task ReadProcessedAsync( string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } }