diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs new file mode 100644 index 00000000..7bea7f50 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs @@ -0,0 +1,136 @@ +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, + }; + + /// ReadRaw over [start, end] ascending, capped, led by AVEVA's boundary row at the query start. + private static FakeHistorianGatewayClient Aveva() + { + var sorted = Gate681.Select(x => (T: At(x.Sec), V: x.Value, x.Null)).ToList(); + return new FakeHistorianGatewayClient + { + RawSource = (start, end, max) => + { + 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)); + } + + [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); + } +}