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; } }