From 6d8a7d48f8baf191c8efdb668f89c82fd4cb685d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 00:40:13 -0400 Subject: [PATCH] tests(grpc): live-verify aggregate + at-time over gRPC at WCF parity The gRPC integration suite was missing live coverage for ReadAggregateAsync and ReadAtTimeAsync, the two tooled gRPC reads the WCF suite already exercises. Add them so the full tooled gRPC surface is live-tested like WCF. - ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows - ReadAggregateAsync_OverGrpc_AcceptsRetrievalMode (Min/MaxWithTime, BestFit) - ReadAtTimeAsync_OverGrpc_ReturnsRequestedTimestamps The aggregate tests self-calibrate their window from a real raw sample (SeedAggregateWindowAsync): the interpolating modes (TimeWeightedAverage / Min/MaxWithTime) do a slow bounding-value scan and return empty when the window has no raw data, so a fixed "last N hours" window blows the per-call deadline against an idle server. Anchoring the window where data actually exists keeps the scan cheap and returns rows on idle or live servers alike. Adds an optional HISTORIAN_GRPC_TIMEOUT knob (per-call deadline override) for slow links. Full tooled gRPC surface now live-green: 15/15 gRPC integration tests pass against a real 2023 R2 server; 313 offline tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../HistorianGrpcIntegrationTests.cs | 126 +++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 36bbdd6..7cfd9cd 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -233,6 +233,124 @@ public sealed class HistorianGrpcIntegrationTests Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01); } + [Fact] + public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + + // Self-calibrate the window from a real raw sample. The 2023 R2 box may be idle (no recent + // collection), so a "last N hours" window can be empty AND make the interpolating modes do a + // slow bounding-value scan. Seeding from where data actually exists makes this robust on any + // server state and keeps the per-bucket scan cheap. See HISTORIAN_GRPC_TIMEOUT for slow links. + (DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!); + if (window is null) + { + return; // tag has no data anywhere in the lookback — nothing to aggregate + } + + List samples = []; + await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync( + testTag!, window.Value.startUtc, window.Value.endUtc, + RetrievalMode.TimeWeightedAverage, + TimeSpan.FromMinutes(10), + CancellationToken.None)) + { + samples.Add(sample); + } + + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + Assert.All(samples, s => Assert.Equal(RetrievalMode.TimeWeightedAverage, s.RetrievalMode)); + } + + // Exercises the "QueryType byte = native enum ordinal" mapping over the gRPC StartQuery envelope + // for a few non-default retrieval modes — the server must accept each without error. Window is + // seeded from real data (idle-server-safe); rows may legitimately be empty for some modes. + [Theory] + [InlineData(RetrievalMode.MinimumWithTime)] + [InlineData(RetrievalMode.MaximumWithTime)] + [InlineData(RetrievalMode.BestFit)] + public async Task ReadAggregateAsync_OverGrpc_AcceptsRetrievalMode(RetrievalMode mode) + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + + (DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!); + if (window is null) + { + return; + } + + List samples = []; + await foreach (HistorianAggregateSample s in client.ReadAggregateAsync( + testTag!, window.Value.startUtc, window.Value.endUtc, mode, TimeSpan.FromMinutes(10), CancellationToken.None)) + { + samples.Add(s); + } + + // Absence of an exception proves the QueryType byte was accepted; pin the echoed mode. + Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode)); + } + + /// + /// Finds a 1-hour window that actually contains raw data for by reading a + /// real raw sample over a wide lookback, then anchoring the window at that sample. Returns null + /// when the tag has no data in the lookback. This keeps the aggregate tests independent of whether + /// the live 2023 R2 box is actively collecting. + /// + private static async Task<(DateTime startUtc, DateTime endUtc)?> SeedAggregateWindowAsync(HistorianClient client, string tag) + { + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(30); + + await foreach (HistorianSample s in client.ReadRawAsync(tag, startUtc, endUtc, maxValues: 1, CancellationToken.None)) + { + DateTime anchor = s.TimestampUtc; + return (anchor - TimeSpan.FromMinutes(1), anchor + TimeSpan.FromHours(1)); + } + + return null; + } + + [Fact] + public async Task ReadAtTimeAsync_OverGrpc_ReturnsRequestedTimestamps() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)) + { + return; + } + + HistorianClient client = new(BuildOptions(host)); + + DateTime nowUtc = DateTime.UtcNow; + DateTime[] timestamps = + [ + nowUtc - TimeSpan.FromDays(1), + nowUtc - TimeSpan.FromHours(12), + nowUtc - TimeSpan.FromHours(1) + ]; + + IReadOnlyList samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None); + + Assert.NotEmpty(samples); + Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); @@ -242,6 +360,11 @@ public sealed class HistorianGrpcIntegrationTests ? parsed : HistorianClientOptions.DefaultGrpcPort; bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase); + // Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate + // modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default. + TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0 + ? TimeSpan.FromSeconds(secs) + : new HistorianClientOptions { Host = host }.RequestTimeout; return new HistorianClientOptions { @@ -253,7 +376,8 @@ public sealed class HistorianGrpcIntegrationTests ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"), IntegratedSecurity = !explicitCreds, UserName = user ?? string.Empty, - Password = password ?? string.Empty + Password = password ?? string.Empty, + RequestTimeout = timeout }; } }