Files
lmxopcua/tests/Drivers/Historian.Conformance/BoundingValueConformance.cs
T
Joseph Doherty 60febbf2dc feat(historian): #691 HistoryRead-Raw ReturnBounds — Part 11 bounding values on every backend
The node manager composes the start/end bounds from each backend's existing raw
reads (one authority, HistoryBounds): at-or-before = a capped reverse read
(native, or ReverseRawReader's walk-back on a forward-only backend — never
AVEVA's synthetic boundary row), at-or-after = a capped forward read. Bounds
count toward NumValuesPerNode, the start bound leads the first page, the end
bound ends the last (carried in the continuation point). Missing bound =
Bad_BoundNotFound (null, at the bound time; one-sided reads ±1 s from the
previous value, Table 1 footnotes a/b). A modified read with ReturnBounds is
Bad_InvalidArgument (Part 11 6.5.3.3).

The .NET SDK's ReadRawModifiedDetails defaults ReturnBounds to TRUE; the
existing tests built details without setting it and now set it false.
Oracle: every row of Part 11 Table 1 over three backend shapes, plus paging
concatenation, ties, Bad bounds, microsecond truncation; conformance pins the
lookup primitives on SqlServer + QuestDB.
2026-10-12 17:52:34 -04:00

101 lines
4.8 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// Gitea #691: the server composes Part 11 bounding values (HistoryRead-Raw <c>ReturnBounds</c>) from exactly these raw
/// reads on a backend that reads newest-first natively (<c>HistoryBounds</c> in the OPC UA server) — so every backend must
/// answer them precisely: "at or before T" = a reverse read of <c>(0, T]</c> capped at one, "at or after T" = a forward read
/// of <c>[T, MaxValue)</c> capped at one, "strictly after T" = the same from <c>T + resolution</c>. A bound is a stored
/// sample of any quality, so a Bad row or an outage marker comes back as the bound.
/// </summary>
public abstract class BoundingValueConformance : ConformanceBase
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly DateTime Floor = new(0, DateTimeKind.Utc);
private static readonly DateTime Ceiling = new(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
private async Task<DataValueSnapshot?> AtOrBeforeAsync(IHistorianDataSource src, string tag, DateTime t) =>
(await src.ReadRawAsync(tag, t, Floor, 1, Ct)).Samples.SingleOrDefault();
private async Task<DataValueSnapshot?> AtOrAfterAsync(IHistorianDataSource src, string tag, DateTime t) =>
(await src.ReadRawAsync(tag, t, Ceiling, 1, Ct)).Samples.SingleOrDefault();
[Fact]
public async Task The_sample_at_or_before_a_time_is_the_latest_one_on_or_before_it()
{
await ConformanceData.SeedAAsync(B);
var src = B.CreateDataSource();
(await AtOrBeforeAsync(src, "A", At(10)))!.Value.ShouldBe(20d); // exactly on it
(await AtOrBeforeAsync(src, "A", At(15)))!.Value.ShouldBe(20d); // between samples
(await AtOrBeforeAsync(src, "A", At(1000)))!.Value.ShouldBe(50d); // past the last
(await AtOrBeforeAsync(src, "A", At(-1))).ShouldBeNull(); // before the first: no bound
(await AtOrBeforeAsync(src, "A", At(15)))!.SourceTimestampUtc!.Value.Ticks.ShouldBe(B.Round(At(10)).Ticks);
}
[Fact]
public async Task The_sample_at_or_after_a_time_is_the_earliest_one_on_or_after_it()
{
await ConformanceData.SeedAAsync(B);
var src = B.CreateDataSource();
(await AtOrAfterAsync(src, "A", At(10)))!.Value.ShouldBe(20d); // exactly on it
(await AtOrAfterAsync(src, "A", At(15)))!.Value.ShouldBe(30d); // between samples
(await AtOrAfterAsync(src, "A", At(-100)))!.Value.ShouldBe(10d); // before the first
(await AtOrAfterAsync(src, "A", At(41))).ShouldBeNull(); // past the last: no bound
}
[Fact]
public async Task The_sample_strictly_after_a_time_starts_one_storage_resolution_later()
{
await ConformanceData.SeedAAsync(B);
var src = B.CreateDataSource();
(await AtOrAfterAsync(src, "A", At(10).AddTicks(B.ResolutionTicks)))!.Value.ShouldBe(30d);
(await AtOrAfterAsync(src, "A", At(40).AddTicks(B.ResolutionTicks))).ShouldBeNull();
}
[Fact]
public async Task A_time_between_storage_ticks_finds_the_sample_the_backend_stores_at_its_truncation()
{
// The server compares "on the bound" at the storage resolution, so the lookups must agree with the window reads:
// a time a fraction of a resolution step after a stored sample is that sample's instant on a coarse backend.
await ConformanceData.SeedAAsync(B);
var src = B.CreateDataSource();
var t = At(10).AddTicks(B.ResolutionTicks - 1); // == At(10) on a 1-tick backend
(await AtOrBeforeAsync(src, "A", t))!.Value.ShouldBe(20d);
var after = await AtOrAfterAsync(src, "A", t);
after!.SourceTimestampUtc!.Value.Ticks.ShouldBeGreaterThanOrEqualTo(B.Round(t).Ticks);
after.Value.ShouldBe(B.Round(t) == B.Round(At(10)) ? 20d : 30d);
}
[Fact]
public async Task A_bad_sample_or_an_outage_marker_is_a_bound_as_stored()
{
await B.SeedAsync("M",
(At(0), HistorianValue.FromDouble(10), Good),
(At(5), HistorianValue.Empty, BadNoCommunication),
(At(7), HistorianValue.FromDouble(99), Bad));
var src = B.CreateDataSource();
var before = await AtOrBeforeAsync(src, "M", At(6));
before!.Value.ShouldBeNull();
before.StatusCode.ShouldBe(0x80310000u);
var after = await AtOrAfterAsync(src, "M", At(6));
after!.Value.ShouldBe(99d);
after.StatusCode.ShouldBe(0x80000000u);
}
[Fact]
public async Task An_unknown_tag_has_no_bounds()
{
var src = B.CreateDataSource();
(await AtOrBeforeAsync(src, "nope", At(10))).ShouldBeNull();
(await AtOrAfterAsync(src, "nope", At(10))).ShouldBeNull();
}
}