using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Gitea #886 — a folder's several event sources read as one: merged in (EventTimeUtc, EventId) order, the cap /// applied to the merge, and "more" never claimed for a stretch a truncated source has not read yet. /// public sealed class EventSourceFanOutTests { private static readonly DateTime T0 = new(2026, 11, 12, 10, 0, 0, DateTimeKind.Utc); private static HistoricalEvent Ev(string id, string source, int second) => new(id, source, T0.AddSeconds(second), T0.AddSeconds(second), null, 1); [Fact] public async Task One_source_is_read_straight_through() { var backend = new StoredEvents(("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2))); var r = await EventSourceFanOut.ReadAsync(backend, ["A"], T0, T0.AddMinutes(1), 10, reverse: false, TestContext.Current.CancellationToken); r.Events.Select(e => e.EventId).ShouldBe(["a1", "a2"]); backend.Requests.ShouldBe(["A"]); } [Fact] public async Task Two_sources_merge_in_time_then_source_then_arrival_order_and_drop_a_duplicate() { // Ties keep each source's own order (z-tie before y-tie: A's arrival), and A's before B's (list order) — never // re-sorted by EventId, which could move an unread tie in front of one already sent. var backend = new StoredEvents( ("A", Ev("a1", "A", 1)), ("A", Ev("a3", "A.X", 3)), ("A", Ev("z-tie", "A", 5)), ("A", Ev("y-tie", "A", 5)), ("B", Ev("b2", "B.Y", 2)), ("B", Ev("a-tie", "B", 5)), ("B", Ev("a1", "A", 1))); var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 10, reverse: false, TestContext.Current.CancellationToken); r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2", "a3", "z-tie", "y-tie", "a-tie"]); r.ContinuationPoint.ShouldBeNull(); backend.Requests.ShouldBe(["A", "B"]); } [Fact] public async Task A_reverse_read_is_the_forward_merge_backwards() { var backend = new StoredEvents( ("A", Ev("a1", "A", 1)), ("A", Ev("z-tie", "A", 5)), ("A", Ev("y-tie", "A", 5)), ("B", Ev("b2", "B", 2)), ("B", Ev("a-tie", "B", 5))); var ct = TestContext.Current.CancellationToken; var forward = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 10, reverse: false, ct); var reverse = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0.AddMinutes(1), T0, 10, reverse: true, ct); // What the server's tie-cluster over-fetch relies on: a forward read of a cluster, reversed, is the reverse order. reverse.Events.Select(e => e.EventId).ShouldBe(forward.Events.Select(e => e.EventId).Reverse()); reverse.Events.Select(e => e.EventId).ShouldBe(["a-tie", "y-tie", "z-tie", "b2", "a1"]); } [Fact] public async Task The_cap_applies_to_the_merge_and_flags_more() { var backend = new StoredEvents(("A", Ev("a1", "A", 1)), ("A", Ev("a3", "A", 3)), ("B", Ev("b2", "B", 2))); var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 2, reverse: false, TestContext.Current.CancellationToken); r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2"]); r.ContinuationPoint.ShouldNotBeNull(); } [Fact] public async Task A_truncated_source_bounds_the_page_at_the_time_it_stopped() { // A default-capped read (maxEvents 0): A stops after two of its three events (at 2); B returns all of its own. // Unbounded, the page would run on to b4 although A's a3 (at 3) is still unread, so the next page would start // after it. The page ends at A's stop, with B's tie at 2 left out because it follows A's (unread) ties there. var backend = new StoredEvents( ("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)), ("A", Ev("a3", "A", 3)), ("B", Ev("b2", "B", 2)), ("B", Ev("b4", "B", 4))) { DefaultCaps = { ["A"] = 2 } }; var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 0, reverse: false, TestContext.Current.CancellationToken); r.Events.Select(e => e.EventId).ShouldBe(["a1", "a2"]); r.ContinuationPoint.ShouldNotBeNull(); } [Fact] public async Task At_the_bound_a_complete_source_ahead_of_the_truncated_one_keeps_its_ties() { var backend = new StoredEvents( ("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)), ("A", Ev("a3", "A", 3)), ("B", Ev("b2", "B", 2)), ("B", Ev("b4", "B", 4))) { DefaultCaps = { ["A"] = 2 } }; // B is listed first, so at 2 its tie precedes A's in merge order and is complete: it stays. var r = await EventSourceFanOut.ReadAsync(backend, ["B", "A"], T0, T0.AddMinutes(1), 0, reverse: false, TestContext.Current.CancellationToken); r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2", "a2"]); r.ContinuationPoint.ShouldNotBeNull(); } [Fact] public async Task A_truncated_source_bounds_a_reverse_page_at_the_oldest_time_it_returned() { var backend = new StoredEvents( ("A", Ev("a3", "A", 3)), ("A", Ev("a4", "A", 4)), ("A", Ev("a5", "A", 5)), ("B", Ev("b4", "B", 4)), ("B", Ev("b1", "B", 1))) { DefaultCaps = { ["A"] = 2 } }; var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0.AddMinutes(1), T0, 0, reverse: true, TestContext.Current.CancellationToken); // A returned 5, 4 and stopped at 4. Reverse merge order at 4 visits B (listed later) first, then A: b4 is // complete and kept, A's a4 closes the page, and b1 (older than A's unread a3) is left for the next page. r.Events.Select(e => e.EventId).ShouldBe(["a5", "b4", "a4"]); r.ContinuationPoint.ShouldNotBeNull(); } [Fact] public async Task Paging_the_merge_from_each_last_event_walks_every_event_once() { // The server's resume rule: read again from the last event's time INCLUSIVE and skip the ties already sent. var stored = new List<(string, HistoricalEvent)>(); for (var i = 0; i < 9; i++) stored.Add(("A", Ev($"a{i}", "A", i))); for (var i = 0; i < 9; i += 2) stored.Add(("B", Ev($"b{i}", "B.X", i))); stored.Add(("B", Ev("b-tie", "B", 4))); var backend = new StoredEvents([.. stored]); var whole = (await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 100, false, TestContext.Current.CancellationToken)) .Events.Select(e => e.EventId).ToList(); foreach (var cap in new[] { 1, 2, 3, 5 }) { var got = new List(); var start = T0; var skip = 0; for (var guard = 0; guard < 100; guard++) { var page = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], start, T0.AddMinutes(1), cap + skip, false, TestContext.Current.CancellationToken); var events = page.Events.SkipWhile((e, i) => i < skip && e.EventTimeUtc == start).Take(cap).ToList(); got.AddRange(events.Select(e => e.EventId)); var more = page.ContinuationPoint is not null || page.Events.Count - Math.Min(skip, page.Events.Count) > cap; if (!more) break; if (events.Count == 0) { skip += cap; continue; } var boundary = events[^1].EventTimeUtc; var trailing = events.AsEnumerable().Reverse().TakeWhile(e => e.EventTimeUtc == boundary).Count(); skip = boundary == start ? skip + trailing : trailing; start = boundary; } got.ShouldBe(whole, $"cap {cap}"); } } /// A backend holding events per source; a read returns that source's events in the window, forward or reverse, /// cut to the cap with a continuation point when more remain. private sealed class StoredEvents(params (string Source, HistoricalEvent Event)[] stored) : IHistorianDataSource { public List Requests { get; } = []; /// A source's own cap for a default-capped read (maxEvents <= 0); uncapped when absent. public Dictionary DefaultCaps { get; } = []; public bool SupportsReverseEvents => true; public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken) { Requests.Add(sourceName); var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc); var rows = stored.Where(s => s.Source == sourceName) .Select(s => s.Event) .Where(e => reverse ? e.EventTimeUtc > lo && e.EventTimeUtc <= hi : e.EventTimeUtc >= lo && e.EventTimeUtc < hi) .OrderBy(e => e.EventTimeUtc) // stable: ties keep storage order, as the gateway's do .ToList(); if (reverse) rows.Reverse(); var cap = maxEvents > 0 ? maxEvents : DefaultCaps.GetValueOrDefault(sourceName ?? string.Empty, 0); var more = cap > 0 && rows.Count > cap; return Task.FromResult(new HistoricalEventsResult(more ? rows.Take(cap).ToList() : rows, more ? [0x01] : null)); } public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken); public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } }