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; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; /// /// #687: a raw HistoryRead must return stored samples only (Part 11, ReturnBounds = false). An AVEVA Full /// raw read starts with a synthetic row stamped exactly at the QUERY start — the value in effect there /// (Quality = 133, the prior sample's value and OPC quality) or, with no earlier data, a NULL row. The fake emits /// the REAL wire shape the live gate captured (2026-10-12): every row carries a value (histsdk puts NumericValue /// on every numeric row), so a NULL row is NumericValue = 0 + OPC quality 0 / Quality = 1 — the evidence /// that a row is synthetic is its timestamp (the query start), never "it has no value". A stored NULL/Bad sample looks /// the same on the wire (q=1 opc=0) and IS a stored sample. /// public sealed class GatewayRawBoundaryRowTests { private static readonly CancellationToken Ct = CancellationToken.None; /// The live gate's tag HistGW.LiveTest.Gate681 (18:06:40 is a stored NULL). private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc); 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, }; /// /// An AVEVA-shaped gateway over : ReadRaw over [start, end] ascending, capped at /// max, led by the boundary row at the query start when nothing is stored exactly there. /// private static FakeHistorianGatewayClient Aveva(IReadOnlyList<(DateTime T, double V, bool Null)> data) { var sorted = data.OrderBy(d => d.T).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; // copies the prior's quality; a Bad prior stays q=1 opc=0 } 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 FakeHistorianGatewayClient Gate() => Aveva(Gate681.Select(x => (At(x.Sec), x.Value, x.Null)).ToList()); private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) => new(fake, NullLogger.Instance); [Fact] public async Task A_window_holding_no_stored_sample_returns_nothing_not_the_value_in_effect() { // Live: Raw [18:06:21, 18:06:39] returned "18:06:21 v=30 Good" — a value never stored at that time. var r = await Source(Gate()).ReadRawAsync("T", At(21), At(39), 0, Ct); r.Samples.ShouldBeEmpty(); } [Fact] public async Task A_window_before_the_first_sample_returns_nothing_not_a_Bad_zero() { // Live: Raw [18:05:00, 18:05:30] returned "18:05:00 v=0 Bad" instead of GoodNoData. var r = await Source(Gate()).ReadRawAsync("T", At(-60), At(-30), 10, Ct); r.Samples.ShouldBeEmpty(); } [Fact] public async Task A_start_only_read_starts_at_the_first_stored_sample() { var r = await Source(Gate()).ReadRawAsync("T", At(30), new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc), 3, Ct); r.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(40), At(50), At(60) }); } [Fact] public async Task A_stored_NULL_sample_in_the_window_is_kept_as_the_Bad_sample_it_is() { var r = await Source(Gate()).ReadRawAsync("T", At(30), At(55), 0, Ct); r.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(40), At(50) }); r.Samples[0].StatusCode.ShouldBe(0x80000000u); } [Fact] public async Task A_stored_sample_exactly_at_the_window_start_is_kept() { var r = await Source(Gate()).ReadRawAsync("T", At(20), At(45), 0, Ct); r.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(20), At(40) }); r.Samples[0].Value.ShouldBe(30d); } [Fact] public async Task The_raw_request_starts_one_millisecond_early_and_asks_for_the_boundary_allowance() { var fake = Gate(); await Source(fake).ReadRawAsync("T", At(30), At(55), 5, Ct); fake.RawCalls.Single().ShouldBe((At(30) - RawAggregateReader.BoundaryMargin, At(55), 5 + RawAggregateReader.PreWindowAllowance)); } [Fact] public async Task An_unlimited_read_stays_unlimited() { var fake = Gate(); await Source(fake).ReadRawAsync("T", At(30), At(55), 0, Ct); fake.RawCalls.Single().MaxValues.ShouldBe(0); } [Fact] public async Task A_full_page_holds_exactly_the_cap_and_the_stream_is_drained_not_abandoned() { var fake = Gate(); var r = await Source(fake).ReadRawAsync("T", At(0), At(200), 3, Ct); r.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(0), At(5), At(20) }); fake.RawStreamsCompleted.ShouldBe(1); } [Theory] [InlineData(1u)] [InlineData(2u)] [InlineData(3u)] public async Task Server_style_paging_returns_every_stored_sample_once_and_nothing_else(uint cap) { // Ties at +60 (three samples) and a stored NULL at +40, over the AVEVA-shaped wire. var data = Gate681.Select(x => (At(x.Sec), x.Value, x.Null)) .Concat([(At(60), 2d, false), (At(60), 3d, false)]).ToList(); var fake = Aveva(data); var ds = Source(fake); var start = At(-10); var end = At(200); var got = new List(); var skip = 0; var cursor = start; var resume = false; for (var guard = 0; guard < 100; guard++) { // The node manager's paging (OtOpcUaNodeManager.ServeRawPagedAsync + HistoryPaging): a full page ⇒ resume at // the last timestamp INCLUSIVE dropping the ties already returned; a short page is the last; a resume that // cannot advance (a tie cluster larger than the cap) over-fetches the cluster with a start == end read. var page = (await ds.ReadRawAsync("T", cursor, end, cap, Ct)).Samples; var full = page.Count >= cap; var samples = resume ? page.SkipWhile((x, i) => i < skip && x.SourceTimestampUtc == cursor).ToList() : page.ToList(); if (resume && full && (samples.Count == 0 || samples[^1].SourceTimestampUtc == cursor)) { var cluster = (await ds.ReadRawAsync("T", cursor, cursor, 1000, Ct)).Samples; var sliceStart = Math.Min(skip, cluster.Count); var sliceCount = Math.Min((int)cap, cluster.Count - sliceStart); got.AddRange(cluster.Skip(sliceStart).Take(sliceCount)); if (sliceStart + sliceCount < cluster.Count) { skip = sliceStart + sliceCount; continue; } cursor = cursor.AddTicks(1); skip = 0; if (cursor > end) break; continue; } got.AddRange(samples); if (!full || samples.Count == 0) break; cursor = samples[^1].SourceTimestampUtc!.Value; skip = samples.AsEnumerable().Reverse().TakeWhile(x => x.SourceTimestampUtc == cursor).Count(); resume = true; } got.Select(s => s.SourceTimestampUtc).ShouldBe(data.Select(d => (DateTime?)d.Item1).OrderBy(t => t).ToArray()); } [Fact] public async Task A_margin_that_swallows_the_row_budget_fails_the_node_rather_than_answering_a_short_page() { // More stored rows inside the 1 ms margin than the allowance: the page could not be read, and a short page would // end the client's paging early — refuse it (a request limit, not a backend fault). var margin = Enumerable.Range(0, 40).Select(i => (At(30).AddTicks(-1 - i), (double)i, false)); var fake = Aveva(margin.Append((At(35), 1d, false)).ToList()); var ds = Source(fake); await Should.ThrowAsync(() => ds.ReadRawAsync("T", At(30), At(40), 5, Ct)); ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); fake.RawStreamsCompleted.ShouldBe(1); } }