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
@@ -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>