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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -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<HistorianAggregateSample> 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<HistorianAggregateSample> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a 1-hour window that actually contains raw data for <paramref name="tag"/> 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.
|
||||
/// </summary>
|
||||
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<HistorianSample> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user