60febbf2dc
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.
101 lines
4.8 KiB
C#
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();
|
|
}
|
|
}
|