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);
}
}