934909b430
1. The at-or-after bound read on a forward-only backend (Gateway) ends at now + ReverseRawReader.LookAhead instead of DateTime.MaxValue (the gateway only checks start <= end, so a year-9999 end reached AVEVA); a bound beyond the horizon is not found without a read. SqlServer/QuestDB keep MaxValue: they bind it safely and may hold samples further ahead. The Gateway fake now refuses far-future ends, so the missing-end-bound test is no longer vacuous. 3. No data and no bound found (whole read empty) => GoodNoData, no values — the pre-bounds answer (6.5.3.2: success only if one or both bounds exist). A one-value page whose start bound is missing first checks there is data or a found end bound. 4. ServerHistorian:HonorReturnBounds (default true); false ignores ReturnBounds as before #691, including the modified-read Bad_InvalidArgument.
175 lines
7.9 KiB
C#
175 lines
7.9 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #691: Part 11 bounding values on the Gateway, composed by the server's <see cref="HistoryBounds"/> over the REAL
|
|
/// source and an AVEVA-shaped fake (the live gate's wire shape). The start bound of a forward read must be the stored
|
|
/// sample before StartTime, at ITS OWN time — never AVEVA's synthetic boundary row, which is stamped at the query start
|
|
/// and carries the value in effect there (<c>Quality = 133</c>). The Gateway reads forward only, so "at or before" is
|
|
/// served by the walk-back (<see cref="ReverseRawReader"/>), and no reverse window is ever sent.
|
|
/// </summary>
|
|
public sealed class GatewayBoundingValueTests
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc);
|
|
private static readonly DateTime Now = T0.AddHours(1);
|
|
|
|
/// <summary>The live gate's tag <c>HistGW.LiveTest.Gate681</c> (18:06:40 is a stored NULL).</summary>
|
|
private static readonly (double Sec, double Value, bool Null)[] Gate681 =
|
|
[
|
|
(0, 10, false), (5, 20, false), (20, 30, false), (40, 0, true), (50, 5, false), (60, 1, false), (90, 1, false),
|
|
(100, 7, false),
|
|
];
|
|
|
|
private static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond));
|
|
|
|
private static HistorianSample Stored(DateTime t, double v, bool isNull) => new()
|
|
{
|
|
Tag = "T",
|
|
Timestamp = Timestamp.FromDateTime(t),
|
|
NumericValue = isNull ? 0 : v,
|
|
OpcQuality = isNull ? 0u : 192u,
|
|
Quality = isNull ? 1u : 0u,
|
|
};
|
|
|
|
/// <summary>The latest query end any ReadRaw was sent (a far-future end is the #691 review's risk).</summary>
|
|
private sealed class EndProbe
|
|
{
|
|
public DateTime MaxEnd = DateTime.MinValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// ReadRaw over <c>[start, end]</c> ascending, capped, led by AVEVA's boundary row at the query start. A query end more
|
|
/// than two days past <see cref="Now"/> is REFUSED (<c>InvalidArgument</c>), modelling the unverified AVEVA behaviour
|
|
/// for a year-9999 end (the HistorianGateway only checks <c>start <= end</c>, so it reaches AVEVA unclamped).
|
|
/// </summary>
|
|
private static FakeHistorianGatewayClient Aveva(EndProbe? probe = null)
|
|
{
|
|
var sorted = Gate681.Select(x => (T: At(x.Sec), V: x.Value, x.Null)).ToList();
|
|
return new FakeHistorianGatewayClient
|
|
{
|
|
RawSource = (start, end, max) =>
|
|
{
|
|
if (probe is not null && end > probe.MaxEnd) probe.MaxEnd = end;
|
|
if (end > Now.AddDays(2))
|
|
throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
|
|
"end_time is outside the historian's time range"));
|
|
var rows = new List<HistorianSample>();
|
|
if (sorted.All(d => d.T != start))
|
|
{
|
|
var prior = sorted.Where(d => d.T < start).Select(d => ((DateTime T, double V, bool Null)?)d).LastOrDefault();
|
|
HistorianSample boundary;
|
|
if (prior is { } p)
|
|
{
|
|
boundary = Stored(start, p.V, p.Null);
|
|
if (!p.Null) boundary.Quality = 133;
|
|
}
|
|
else
|
|
{
|
|
boundary = new HistorianSample { Tag = "T", Timestamp = Timestamp.FromDateTime(start), NumericValue = 0, OpcQuality = 0, Quality = 1 };
|
|
}
|
|
|
|
rows.Add(boundary);
|
|
}
|
|
|
|
rows.AddRange(sorted.Where(d => d.T >= start && d.T <= end).Select(d => Stored(d.T, d.V, d.Null)));
|
|
return max > 0 ? rows.Take(max) : rows;
|
|
},
|
|
};
|
|
}
|
|
|
|
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) =>
|
|
new(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
|
|
[Fact]
|
|
public async Task The_start_bound_is_the_stored_prior_sample_at_its_own_time_not_the_synthetic_row()
|
|
{
|
|
var bound = await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(21), reverse: false, 65536, Now, Ct);
|
|
|
|
bound.ShouldNotBeNull();
|
|
bound.SourceTimestampUtc.ShouldBe(At(20)); // the synthetic row would be stamped At(21)
|
|
bound.Value.ShouldBe(30d);
|
|
bound.StatusCode.ShouldBe(0u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stored_NULL_prior_sample_is_the_start_bound_as_the_Bad_sample_it_is()
|
|
{
|
|
var bound = await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(45), reverse: false, 65536, Now, Ct);
|
|
|
|
bound!.SourceTimestampUtc.ShouldBe(At(40));
|
|
bound.StatusCode.ShouldBe(0x80000000u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Before_the_first_sample_the_start_bound_is_not_found_at_the_start_time()
|
|
{
|
|
var bound = await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(-30), reverse: false, 65536, Now, Ct);
|
|
|
|
bound!.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
|
|
bound.SourceTimestampUtc.ShouldBe(At(-30));
|
|
bound.Value.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_sample_exactly_on_the_start_time_is_the_bound_and_already_the_data()
|
|
{
|
|
(await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(20), reverse: false, 65536, Now, Ct)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_end_bound_is_the_first_stored_sample_at_or_after_the_end_time()
|
|
{
|
|
var source = Source(Aveva());
|
|
var after = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(41)), At(40), 65536, Now, Ct);
|
|
after.SourceTimestampUtc.ShouldBe(At(50));
|
|
after.Value.ShouldBe(5d);
|
|
|
|
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(101)), At(100), 65536, Now, Ct);
|
|
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
|
|
missing.SourceTimestampUtc.ShouldBe(At(101));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #691 review: the at-or-after read never sends an end beyond now + <see cref="ReverseRawReader.LookAhead"/> (the
|
|
/// historian stores nothing further ahead), and an end bound wanted beyond that horizon is not found without a read.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_at_or_after_read_never_asks_past_now_plus_one_day()
|
|
{
|
|
var probe = new EndProbe();
|
|
var fake = Aveva(probe);
|
|
var source = Source(fake);
|
|
|
|
var found = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(41)), At(40), 65536, Now, Ct);
|
|
found.SourceTimestampUtc.ShouldBe(At(50));
|
|
probe.MaxEnd.ShouldBeLessThanOrEqualTo(Now + ReverseRawReader.LookAhead.Add(TimeSpan.FromMilliseconds(1)));
|
|
|
|
var calls = fake.ReadRawCallCount;
|
|
var beyond = Now.AddDays(3);
|
|
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct);
|
|
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
|
|
missing.SourceTimestampUtc.ShouldBe(beyond);
|
|
fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read");
|
|
source.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reverse_read_end_bound_walks_back_to_the_stored_sample_at_or_before_the_end_time()
|
|
{
|
|
var fake = Aveva();
|
|
var bound = await HistoryBounds.EndBoundAsync(Source(fake), "T", HistoryBounds.For(At(100), At(55)), At(60), 65536, Now, Ct);
|
|
|
|
bound.SourceTimestampUtc.ShouldBe(At(50));
|
|
bound.Value.ShouldBe(5d);
|
|
}
|
|
}
|