Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/PagingRegressionTests.cs
T

119 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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 <c>NodeManagerHistoryReadPagingTests</c> runs on a fake that models them).
/// <c>HistoryPaging</c> 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 <see cref="IHistorianDataSource.TimestampResolutionTicks"/>) over the real reads.
/// </summary>
[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!;
/// <summary>Sample i at T0 + i µs (adjacent microseconds: a one-tick step lands strictly between two of them).</summary>
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<DateTime?[]> 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);
}
/// <summary>The node manager's Raw paging loop at <c>NumValuesPerNode = 1</c> (see <c>ServeRawPagedAsync</c>).</summary>
private static async Task<List<DateTime?>> WalkAsync(IHistorianDataSource ds, DateTime start, DateTime end)
{
const uint cap = 1;
var res = ds.TimestampResolutionTicks;
var reverse = start > end;
var collected = new List<DateTime?>();
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;
}
}