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