using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.HistorianGateway.Client; 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 #686: the gateway's ReadRaw streams ascending only and rejects start > end with /// InvalidArgument, which used to be recorded as a backend health failure. The source declares it cannot read /// newest-first (so the server walks back with forward reads), and refuses a reverse window itself without sending /// anything or counting a failure. The walk itself is driven here over the REAL source and an AVEVA-shaped fake. /// public sealed class GatewayRawDirectionTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc); private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) => new(fake, NullLogger.Instance); [Fact] public void The_gateway_source_declares_it_cannot_read_raw_newest_first() { IHistorianDataSource ds = Source(new FakeHistorianGatewayClient()); ds.SupportsReverseRaw.ShouldBeFalse(); } [Fact] public async Task A_reverse_raw_window_is_refused_unsent_and_is_not_a_backend_failure() { var fake = new FakeHistorianGatewayClient(); IHistorianDataSource ds = Source(fake); var end = new DateTime(2026, 10, 12, 18, 7, 45, DateTimeKind.Utc); await Should.ThrowAsync(() => ds.ReadRawAsync("Gate681", end, new DateTime(0, DateTimeKind.Utc), 3, Ct)); fake.ReadRawCallCount.ShouldBe(0); var health = ds.GetHealthSnapshot(); health.TotalFailures.ShouldBe(0); health.ConsecutiveFailures.ShouldBe(0); // Positive control: a forward read of the same window is sent. await ds.ReadRawAsync("Gate681", new DateTime(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc), end, 3, Ct); fake.ReadRawCallCount.ShouldBe(1); } /// /// An uncapped raw read (NumValuesPerNode = 0, "all values") is sent as an unbounded max_values. The /// client package and the gateway both reject max_values <= 0, so forwarding 0 failed every uncapped raw /// read — forward, and the one-shot read an uncapped reverse read is served from (#686 review). /// [Fact] public async Task An_uncapped_raw_read_asks_the_gateway_for_an_unbounded_max_values() { var fake = new FakeHistorianGatewayClient(); await Source(fake).ReadRawAsync("T", T0, T0.AddHours(1), 0, Ct); fake.LastReadRawMaxValues.ShouldBe(int.MaxValue); } [Fact] public async Task A_deadline_cancellation_is_not_a_backend_failure() { var fake = new FakeHistorianGatewayClient { RawSamples = [Wire(T0, 1)] }; var ds = Source(fake); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync(() => ds.ReadRawAsync("T", T0, T0.AddHours(1), 10, cts.Token)); ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task The_gateways_max_values_ceiling_rejection_of_a_raw_read_is_a_request_limit_not_a_backend_fault() { var rpc = new RpcException(new Status(StatusCode.InvalidArgument, "max_values exceeds the server ceiling of 1000000; read large ranges by time-slicing ...")); var ds = Source(new FakeHistorianGatewayClient { ThrowOnRead = new HistorianGatewayException(rpc.Status.Detail, rpc) }); await Should.ThrowAsync(() => ds.ReadRawAsync("T", T0, T0.AddHours(1), 0, Ct)); ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } // ---- the walk-back over the real source --------------------------------------------------------------------------- private static HistorianSample Wire(DateTime t, double v, uint opc = 192) => new() { Tag = "T", Timestamp = Timestamp.FromDateTime(t), NumericValue = v, OpcQuality = opc }; /// /// A gateway over stored samples (value i at T0 + i s, i = 0..299) shaped like AVEVA: ReadRaw rejects /// start > end and max_values <= 0, returns [start, end] ascending capped at /// max_values, and first emits a row stamped at the query start carrying the value in effect there /// (Quality = 133), or a NULL row (OpcQuality = 0, value 0) when there is none. /// private static FakeHistorianGatewayClient AvevaStore() { var stored = Enumerable.Range(0, 300).Select(i => (Time: T0.AddSeconds(i), Value: (double)i)).ToList(); return new FakeHistorianGatewayClient { RawSource = (start, end, max) => { if (start > end) throw new RpcException(new Status(StatusCode.InvalidArgument, "start_time must be less than or equal to end_time.")); if (max <= 0) throw new ArgumentOutOfRangeException(nameof(max)); var rows = new List(); if (stored.All(s => s.Time != start)) { var prior = stored.LastOrDefault(s => s.Time < start); var boundary = prior.Time == default ? new HistorianSample { Tag = "T", NumericValue = 0, OpcQuality = 0, Quality = 1 } : Wire(prior.Time, prior.Value); boundary.Timestamp = Timestamp.FromDateTime(start); if (prior.Time != default) boundary.Quality = 133; rows.Add(boundary); } rows.AddRange(stored.Where(s => s.Time >= start && s.Time <= end).Select(s => Wire(s.Time, s.Value))); return rows.Take(max); }, }; } private static void ShouldOnlyHaveSentValidForwardReads(FakeHistorianGatewayClient fake) { fake.RawCalls.ShouldNotBeEmpty(); foreach (var (start, end, max) in fake.RawCalls) { start.ShouldBeLessThanOrEqualTo(end); max.ShouldBeGreaterThan(0); start.ShouldBeGreaterThanOrEqualTo(DateTime.UnixEpoch); } fake.RawStreamsCompleted.ShouldBe(fake.ReadRawCallCount); // no gateway stream abandoned (historiangw#26) } [Fact] public async Task A_capped_reverse_read_walked_back_over_the_gateway_returns_the_latest_values_newest_first() { var fake = AvevaStore(); var ds = Source(fake); var page = await ReverseRawReader.ReadAsync(ds, "T", hi: T0.AddSeconds(200), lo: T0.AddSeconds(50), maxValues: 10, maxTieCluster: 65536, nowUtc: T0.AddHours(1), widthHintTicks: 0, Ct); page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(191, 10).Reverse().Select(i => (double)i)); page.Samples.ShouldAllBe(s => s.StatusCode == 0u); ShouldOnlyHaveSentValidForwardReads(fake); ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task An_end_only_read_walked_back_over_the_gateway_reaches_the_first_sample_and_no_fabricated_row() { var fake = AvevaStore(); var ds = Source(fake); var page = await ReverseRawReader.ReadAsync(ds, "T", hi: T0.AddSeconds(2), lo: new DateTime(0, DateTimeKind.Utc), maxValues: 10, maxTieCluster: 65536, nowUtc: T0.AddHours(1), widthHintTicks: 0, Ct); // 2, 1, 0 — and not the NULL row AVEVA fabricates at each query start before the first sample. page.Samples.Select(s => (double)s.Value!).ShouldBe([2d, 1d, 0d]); ShouldOnlyHaveSentValidForwardReads(fake); } [Fact] public async Task An_uncapped_reverse_read_over_the_gateway_is_one_forward_read() { var fake = AvevaStore(); var ds = Source(fake); var page = await ReverseRawReader.ReadAsync(ds, "T", hi: T0.AddSeconds(200), lo: T0.AddSeconds(50), maxValues: 0, maxTieCluster: 65536, nowUtc: T0.AddHours(1), widthHintTicks: 0, Ct); page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(51, 150).Reverse().Select(i => (double)i)); fake.ReadRawCallCount.ShouldBe(1); ShouldOnlyHaveSentValidForwardReads(fake); } }