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; /// /// #685: the AVEVA-native TimeAverage / Total rows. The fake emits the REAL wire shape captured by the live /// AVEVA gate (2026-10-12): AVEVA always adds one row for the interval BEFORE the window ([start − interval, start), /// carrying a real value when data exists there, a no-data row otherwise — and, under historiangw#27, starting days /// early), and marks an interval holding a NULL/Bad sample opc_quality = 64 while still supplying its value. /// Every row carries a value on the wire (proto3 non-optional double; a no-data row is 0 + a Bad OPC quality). /// public sealed class GatewayNativeAggregateRowsTests { private static readonly CancellationToken Ct = CancellationToken.None; /// 2026-10-12 18:06:00 UTC — the live gate's tag HistGW.LiveTest.Gate681. private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc); private static readonly TimeSpan I20 = TimeSpan.FromSeconds(20); private const uint GoodCalc = 0x00000401u; private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal + Calculated, as the SQL backends' aggregates private const uint NoDataCalc = 0x809B0401u; private static HistorianAggregateSample Row(DateTime? start, DateTime? end, double value, uint opc) => new() { Tag = "T", StartTime = start is { } s ? Timestamp.FromDateTime(s) : null, EndTime = end is { } e ? Timestamp.FromDateTime(e) : null, Value = value, OpcQuality = opc, }; private static async Task> Read(IReadOnlyList wire, DateTime start, DateTime end, HistoryAggregateType aggregate = HistoryAggregateType.TimeAverage) { var fake = new FakeHistorianGatewayClient { AggregateSamples = wire }; var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); return (await ds.ReadProcessedAsync("T", start, end, I20, aggregate, Ct)).Samples; } // ---- D1: rows outside the requested window ----------------------------------------------------------------------- [Fact] public async Task The_leading_pre_window_row_carrying_a_real_value_is_dropped() { // Live: TimeAverage over [18:06:20, 18:07:20) returned "18:06:00 22.5 Good" first — the interval before the window. var start = T0.AddSeconds(20); var wire = new[] { Row(T0, start, 22.5, 192), Row(start, start + I20, 30, 192), Row(start + I20, start + 2 * I20, 3, 64), Row(start + 2 * I20, start + 3 * I20, 1, 192), }; var s = await Read(wire, start, start + 3 * I20); s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { start, start + I20, start + 2 * I20 }); s[0].Value.ShouldBe(30d); } [Fact] public async Task A_no_data_pre_window_row_is_dropped_rather_than_answered_BadNoData() { var wire = new[] { Row(T0 - I20, T0, 0, 0), Row(T0, T0 + I20, 22.5, 192) }; var s = await Read(wire, T0, T0 + I20, HistoryAggregateType.Total); s.Count.ShouldBe(1); s[0].SourceTimestampUtc.ShouldBe(T0); s[0].StatusCode.ShouldBe(GoodCalc); } [Fact] public async Task A_pre_window_row_starting_days_early_is_dropped() { // historiangw#27: the interval reaches AVEVA 10,000× too large, so the leading row starts days before the window. var wire = new[] { Row(T0.AddSeconds(-200_000), T0, 0, 0), Row(T0, T0.AddMinutes(2), 11.727, 64) }; var s = await Read(wire, T0, T0.AddMinutes(2)); s.Count.ShouldBe(1); s[0].SourceTimestampUtc.ShouldBe(T0); } [Fact] public async Task A_row_whose_interval_starts_at_or_after_the_window_end_is_dropped() { var wire = new[] { Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) }; var s = await Read(wire, T0, T0 + I20); s.Count.ShouldBe(1); s[0].Value.ShouldBe(1d); } [Fact] public async Task Without_a_start_time_a_row_is_placed_by_its_end_time() { // Mapper fallback: no StartTime ⇒ stamped with its EndTime. The pre-window row then ENDS at the window start (out); // an in-window row ends inside (lo, hi] (in). var wire = new[] { Row(null, T0, 5, 192), Row(null, T0 + I20, 6, 192), Row(null, T0 + 2 * I20, 7, 192) }; var s = await Read(wire, T0, T0 + 2 * I20); s.Select(x => x.Value).ShouldBe(new object?[] { 6d, 7d }); } [Fact] public async Task The_window_filter_is_direction_agnostic() { // A reverse request (start > end) covers the same span (end, start]; a row for the interval before the span is out. // #691.2: served by a forward request on the later-anchored grid, newest first, stamped with each later edge. var wire = new[] { Row(T0 - I20, T0, 9, 192), Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) }; var s = await Read(wire, T0 + 2 * I20, T0); s.Select(x => x.Value).ShouldBe(new object?[] { 2d, 1d }); s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { T0 + 2 * I20, T0 + I20 }); } // ---- D2: an Uncertain row keeps its value ------------------------------------------------------------------------ [Fact] public async Task An_Uncertain_row_keeps_its_value_with_an_Uncertain_Calculated_status() { // Live: interval 18:06:40 holds the NULL sample ⇒ AVEVA TWA 3 / Integral 30 with opc_quality 64. var at = T0.AddSeconds(40); var s = await Read([Row(at, at + I20, 3, 64)], at, at + I20); s.Single().Value.ShouldBe(3d); s.Single().StatusCode.ShouldBe(UncertainCalc); } [Theory] [InlineData(64u)] [InlineData(68u)] [InlineData(191u)] [InlineData(0x1_0040u)] // only the low byte is the OPC DA quality (as the raw mapping reads it) public async Task Every_Uncertain_family_quality_keeps_its_value(uint opc) { var s = await Read([Row(T0, T0 + I20, 4.5, opc)], T0, T0 + I20); s.Single().Value.ShouldBe(4.5d); s.Single().StatusCode.ShouldBe(UncertainCalc); } [Theory] [InlineData(0u)] [InlineData(1u)] [InlineData(63u)] public async Task A_Bad_family_row_is_BadNoData_with_no_value_although_the_wire_carries_one(uint opc) { var s = await Read([Row(T0, T0 + I20, 0, opc)], T0, T0 + I20); s.Single().Value.ShouldBeNull(); s.Single().StatusCode.ShouldBe(NoDataCalc); } [Theory] [InlineData(192u)] [InlineData(216u)] public async Task A_Good_family_row_is_Good_Calculated(uint opc) { var s = await Read([Row(T0, T0 + I20, 2.5, opc)], T0, T0 + I20); s.Single().Value.ShouldBe(2.5d); s.Single().StatusCode.ShouldBe(GoodCalc); } }