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; /// /// Gitea #691: Part 11 bounding values on the Gateway, composed by the server's 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 (Quality = 133). The Gateway reads forward only, so "at or before" is /// served by the walk-back (), and no reverse window is ever sent. /// 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); /// The live gate's tag HistGW.LiveTest.Gate681 (18:06:40 is a stored NULL). 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, }; /// The latest query end any ReadRaw was sent (a far-future end is the #691 review's risk). private sealed class EndProbe { public DateTime MaxEnd = DateTime.MinValue; } /// /// ReadRaw over [start, end] ascending, capped, led by AVEVA's boundary row at the query start. A query end more /// than two days past is REFUSED (InvalidArgument), modelling the unverified AVEVA behaviour /// for a year-9999 end (the HistorianGateway only checks start <= end, so it reaches AVEVA unclamped). /// 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(); 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.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)); } /// /// #691 review: the at-or-after read never sends an end beyond now + (the /// historian stores nothing further ahead), and an end bound wanted beyond that horizon is not found without a read. /// [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); } }