diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/PagingRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/PagingRegressionTests.cs new file mode 100644 index 00000000..642c6b8f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/PagingRegressionTests.cs @@ -0,0 +1,118 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests; + +/// +/// C1 (T7 review I-1): the facts the server's HistoryRead-Raw paging relies on, pinned against the REAL QuestDB data +/// source (the node-manager walk in NodeManagerHistoryReadPagingTests runs on a fake that models them). +/// HistoryPaging is internal to OpcUaServer and not visible from this project, so the end-to-end walk below +/// re-states the node manager's cursor arithmetic (resume inclusive + boundary trim; stalled ⇒ point-read the +/// cluster and step off by ) over the real reads. +/// +[Collection(QuestDbCollection.Name)] +public sealed class PagingRegressionTests(QuestDbFixture fx) : IAsyncLifetime +{ + private static readonly CancellationToken Ct = CancellationToken.None; + private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); // µs-aligned + private QuestDbConformanceBackend _b = null!; + + /// Sample i at T0 + i µs (adjacent microseconds: a one-tick step lands strictly between two of them). + private static DateTime S(int i) => T0.AddTicks(10L * i); + + public async ValueTask InitializeAsync() + { + _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx); + await _b.SeedAsync("P", Enumerable.Range(0, 6) + .Select(i => (S(i), HistorianValue.FromDouble(i), ConformanceData.Good)).ToArray()); + } + + public async ValueTask DisposeAsync() + { + if (_b is not null) await _b.DisposeAsync(); + } + + private async Task RawAsync(IHistorianDataSource ds, DateTime start, DateTime end, uint max) => + (await ds.ReadRawAsync("P", start, end, max, Ct)).Samples.Select(s => s.SourceTimestampUtc).ToArray(); + + [Fact] + public async Task Forward_raw_reads_truncate_one_tick_but_advance_by_one_resolution_step() + { + var ds = _b.CreateDataSource(); + var end = T0.AddHours(1); + var res = ds.TimestampResolutionTicks; + + (await RawAsync(ds, S(2).AddTicks(1), end, 1)).ShouldBe([S(2)]); // T + 1 tick ⇒ back to T (the C1 loop) + (await RawAsync(ds, S(2).AddTicks(res), end, 1)).ShouldBe([S(3)]); // T + resolution ⇒ the next sample + (await RawAsync(ds, S(2), S(2), 10)).ShouldBe([S(2)]); // point read at T ⇒ T + } + + [Fact] + public async Task Reverse_raw_reads_step_down_by_one_resolution_step() + { + var ds = _b.CreateDataSource(); + var end = T0.AddHours(-1); + var res = ds.TimestampResolutionTicks; + + (await RawAsync(ds, S(3).AddTicks(-1), end, 1)).ShouldBe([S(2)]); // T − 1 tick truncates DOWN ⇒ previous + (await RawAsync(ds, S(3).AddTicks(-res), end, 1)).ShouldBe([S(2)]); // T − resolution ⇒ previous + (await RawAsync(ds, S(3), end, 1)).ShouldBe([S(3)]); // reverse window (end, start] includes start + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task One_value_per_page_walk_returns_every_sample_once_and_terminates(bool reverse) + { + var ds = _b.CreateDataSource(); + var (start, end) = reverse ? (T0.AddHours(1), T0.AddHours(-1)) : (T0.AddHours(-1), T0.AddHours(1)); + var collected = await WalkAsync(ds, start, end); + var expected = Enumerable.Range(0, 6).Select(i => (DateTime?)S(i)); + collected.ShouldBe(reverse ? expected.Reverse() : expected); + } + + /// The node manager's Raw paging loop at NumValuesPerNode = 1 (see ServeRawPagedAsync). + private static async Task> WalkAsync(IHistorianDataSource ds, DateTime start, DateTime end) + { + const uint cap = 1; + var res = ds.TimestampResolutionTicks; + var reverse = start > end; + var collected = new List(); + DateTime? cursor = start; + var skip = 0; + var resumed = false; + for (var pages = 0; cursor is { } from; pages++) + { + pages.ShouldBeLessThan(60, "paging must terminate, not loop"); + var raw = (await ds.ReadRawAsync("P", from, end, cap, Ct)).Samples; + var full = raw.Count >= cap; + var page = raw.ToList(); + if (resumed) + for (var dropped = 0; dropped < skip && page.Count > 0 && page[0].SourceTimestampUtc == from; dropped++) + page.RemoveAt(0); + + if (resumed && full && (page.Count == 0 || page[^1].SourceTimestampUtc == from)) + { + // Stalled on the boundary "cluster": point-read it, emit what is left, step off by the resolution. + var cluster = (await ds.ReadRawAsync("P", from, from, 1000, Ct)).Samples; + var sliceStart = Math.Min(skip, cluster.Count); + var slice = cluster.Skip(sliceStart).Take((int)cap).ToList(); + collected.AddRange(slice.Select(s => s.SourceTimestampUtc)); + if (sliceStart + slice.Count < cluster.Count) { skip = sliceStart + slice.Count; continue; } + var next = from.AddTicks(reverse ? -res : res); + cursor = reverse ? (next > end ? next : null) : (next <= end ? next : null); + skip = 0; + continue; + } + + collected.AddRange(page.Select(s => s.SourceTimestampUtc)); + if (!full || page.Count == 0) break; + cursor = page[^1].SourceTimestampUtc; + skip = page.Count(s => s.SourceTimestampUtc == cursor); + resumed = true; + } + return collected; + } +}