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