f661c084d8
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
119 lines
5.7 KiB
C#
119 lines
5.7 KiB
C#
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;
|
||
}
|
||
}
|