fix(opcua): raw open-ended/end-only reads per Part 11; extreme-bound conformance (#669)

This commit is contained in:
Joseph Doherty
2026-10-11 08:37:24 -04:00
parent acce99de9a
commit 50b504d293
3 changed files with 88 additions and 1 deletions
@@ -40,6 +40,22 @@ public abstract class RawReadConformance : ConformanceBase
Values(await B.CreateDataSource().ReadRawAsync("A", At(-1), At(100), uint.MaxValue, Ct)).Length.ShouldBe(5);
}
/// <summary>
/// #669: the node manager now pages an open-ended read (Part 11: only a StartTime / only an EndTime) with
/// <c>DateTime.MaxValue</c> / <c>DateTime.MinValue</c> as the missing bound, so every backend must bind both
/// extremes without overflow, ±infinity or a cast error.
/// </summary>
[Fact]
public async Task Extreme_bounds_MaxValue_and_MinValue_are_safe()
{
await ConformanceData.SeedAAsync(B);
var max = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
var min = new DateTime(0, DateTimeKind.Utc);
Values(await B.CreateDataSource().ReadRawAsync("A", At(10), max, 2, Ct)).ShouldBe([20d, 30d]);
Values(await B.CreateDataSource().ReadRawAsync("A", At(100), min, 2, Ct)).ShouldBe([50d, 40d]);
Values(await B.CreateDataSource().ReadRawAsync("A", At(10), max, 0, Ct)).ShouldBe([20d, 30d, 40d, 50d]);
}
[Fact]
public async Task Reverse_read_is_descending_over_end_exclusive_start_inclusive()
{
@@ -73,6 +73,65 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Part 11: only a StartTime (EndTime unset = MinValue) with a cap pages FORWARD to an open upper bound —
/// it must not be mistaken for a reverse read, and the stored cursor must not carry MinValue as its end.</summary>
[Fact]
public async Task Raw_open_ended_forward_read_pages_ascending_exactly_once()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.EnsureVariable("eq-1/open", parentFolderNodeId: null, displayName: "Open", dataType: "Double",
writable: false, historianTagname: "WW.Open", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/open")!.NodeId;
var collected = new List<double>();
byte[]? cp = null;
var pages = 0;
do
{
var (values, error, next) = ReadRaw(nm, nodeId, Epoch, DateTime.MinValue, max: 100, inboundCp: cp);
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
collected.AddRange(values);
cp = next;
(++pages).ShouldBeLessThan(20);
}
while (cp is not null);
collected.ShouldBe(Enumerable.Range(0, 250).Select(i => (double)i));
await host.DisposeAsync();
}
/// <summary>Part 11: only an EndTime (StartTime unset = MinValue) with a cap reads BACKWARD from the EndTime.</summary>
[Fact]
public async Task Raw_end_only_read_pages_descending_from_the_end_time()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.EnsureVariable("eq-1/endonly", parentFolderNodeId: null, displayName: "EndOnly", dataType: "Double",
writable: false, historianTagname: "WW.EndOnly", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/endonly")!.NodeId;
var collected = new List<double>();
byte[]? cp = null;
var pages = 0;
do
{
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100, inboundCp: cp);
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
collected.AddRange(values);
cp = next;
(++pages).ShouldBeLessThan(20);
}
while (cp is not null);
collected.ShouldBe(Enumerable.Range(0, 250).Reverse().Select(i => (double)i));
await host.DisposeAsync();
}
/// <summary>An exactly-full series whose total is a multiple of the cap pages to a trailing
/// EMPTY page (GoodNoData, no CP) — the server can't know the prior page was the last until it reads
/// past the end.</summary>