78f604e0cd
Average, Count, Minimum, Maximum, Range, Start, End, Delta and Minimum/MaximumActualTime are evaluated on the Gateway backend from forward ReadRaw reads with BucketPlan + AggregateEvaluator, so Gateway matches SqlServer/QuestDB exactly (values, status bits, interval-start stamps, #677 reverse). - Window read [start - 1 ms, end] so AVEVA's initial-value row never lands in a bucket; a pre-window row proves earlier data only with a non-Bad OPC quality (histsdk puts a value on every NULL row). - Earlier data otherwise from a 2-row [1970, start) probe; later data from a 2-row [end, now+1d] look-ahead; both ignore rows stamped at their query start/end. - No gateway stream is ever abandoned (a client-side cancel makes the gateway evict its shared historian session); past ServerHistorian:MaxTimeWeightedRawRows the bounded stream is drained, then BadTooManyOperations. The gateway's max_values-ceiling InvalidArgument maps to the same limit; a caller cancellation is not a backend fault. - TimeAverage/Total stay AVEVA-native (no prior-sample read for exact Part 13 bounds), now stamped with the interval start (PENDING the live leg: histsdk's tail+43 start_time field is unverified) and carrying the Calculated bits. - Served set: GatewayHistorianDataSource.ServedAggregates (12). Live AVEVA leg open (VPN).
143 lines
7.6 KiB
C#
143 lines
7.6 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
public sealed class GatewayHistorianDataSourceTests
|
|
{
|
|
[Fact]
|
|
public async Task ReadRaw_maps_samples_and_passes_args()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
RawSamples = new[]
|
|
{
|
|
new HistorianSample { Tag = "T", NumericValue = 1.0, OpcQuality = 192, Timestamp = Ts(2026, 1, 1, 0, 0, 0) },
|
|
new HistorianSample { Tag = "T", NumericValue = 2.0, OpcQuality = 0, Timestamp = Ts(2026, 1, 1, 0, 0, 1) },
|
|
},
|
|
};
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var r = await ds.ReadRawAsync("T", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 100, TestContext.Current.CancellationToken);
|
|
Assert.Equal(2, r.Samples.Count);
|
|
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // Bad from quality 0
|
|
Assert.Equal("T", fake.LastReadRawTag);
|
|
Assert.Equal(100, fake.LastReadRawMaxValues);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadProcessed_uses_aggregate_mode_mapping()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.Total, TestContext.Current.CancellationToken);
|
|
Assert.Equal(RetrievalMode.Integral, fake.LastAggregateMode);
|
|
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
|
|
}
|
|
|
|
[Fact] // #681: the native buckets are stamped with the interval START and carry the Calculated bits
|
|
public async Task ReadProcessed_native_buckets_are_stamped_at_the_interval_start_with_the_Calculated_bit()
|
|
{
|
|
var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
AggregateSamples = new[]
|
|
{
|
|
new HistorianAggregateSample { Value = 7.0, OpcQuality = 192, StartTime = Timestamp.FromDateTime(start), EndTime = Timestamp.FromDateTime(start.AddMinutes(1)) },
|
|
new HistorianAggregateSample { OpcQuality = 0, StartTime = Timestamp.FromDateTime(start.AddMinutes(1)), EndTime = Timestamp.FromDateTime(start.AddMinutes(2)) },
|
|
},
|
|
};
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var r = await ds.ReadProcessedAsync("T", start, start.AddMinutes(2), TimeSpan.FromMinutes(1), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken);
|
|
Assert.Equal(new DateTime?[] { start, start.AddMinutes(1) }, r.Samples.Select(x => x.SourceTimestampUtc).ToArray());
|
|
Assert.Equal(new[] { 0x00000401u, 0x809B0401u }, r.Samples.Select(x => x.StatusCode).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadProcessed_TimeAverage_requests_TimeWeightedAverage()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken);
|
|
Assert.Equal(RetrievalMode.TimeWeightedAverage, fake.LastAggregateMode);
|
|
}
|
|
|
|
[Theory] // #681 (was #679 BadAggregateNotSupported): served from a raw read, never a native retrieval mode
|
|
[InlineData(HistoryAggregateType.Average)]
|
|
[InlineData(HistoryAggregateType.Count)]
|
|
[InlineData(HistoryAggregateType.Minimum)]
|
|
[InlineData(HistoryAggregateType.Maximum)]
|
|
public async Task ReadProcessed_per_sample_aggregate_reads_raw_not_a_retrieval_mode(HistoryAggregateType aggregate)
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
await ds.ReadProcessedAsync("T", t0, t0.AddMinutes(1), TimeSpan.FromSeconds(60), aggregate, TestContext.Current.CancellationToken);
|
|
Assert.Equal(0, fake.ReadAggregateCallCount);
|
|
Assert.True(fake.ReadRawCallCount >= 1); // the window, plus the earlier-data probe when the range matters
|
|
}
|
|
|
|
[Theory] // no AVEVA mode and not a per-sample aggregate: not supported, gateway never called, no failure recorded
|
|
[InlineData(HistoryAggregateType.TimeAverage2)]
|
|
[InlineData(HistoryAggregateType.PercentGood)]
|
|
public async Task ReadProcessed_unsupported_aggregate_throws_without_calling_gateway_or_recording_failure(HistoryAggregateType aggregate)
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
await Assert.ThrowsAsync<HistoryAggregateNotSupportedException>(() => ds.ReadProcessedAsync(
|
|
"T", default, default, TimeSpan.FromSeconds(60), aggregate, TestContext.Current.CancellationToken));
|
|
Assert.Equal(0, fake.ReadAggregateCallCount);
|
|
Assert.Equal(0, fake.ReadRawCallCount);
|
|
var h = ds.GetHealthSnapshot();
|
|
Assert.Equal(0, h.TotalFailures);
|
|
Assert.Equal(0, h.TotalQueries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var t1 = t0.AddSeconds(1);
|
|
fake.AtTimeSamples = new[] { new HistorianSample { NumericValue = 5.0, OpcQuality = 192, Timestamp = Timestamp.FromDateTime(t0) } };
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var r = await ds.ReadAtTimeAsync("T", new[] { t0, t1 }, TestContext.Current.CancellationToken);
|
|
Assert.Equal(2, r.Samples.Count); // exactly one per requested ts, in order
|
|
Assert.Equal(5.0, r.Samples[0].Value);
|
|
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // gap → Bad at requested ts
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_window_is_not_a_fault()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var r = await ds.ReadRawAsync("T", default, default, 10, TestContext.Current.CancellationToken);
|
|
Assert.Empty(r.Samples); // GoodNoData-empty, no throw
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disposing_data_source_disposes_client()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
await ds.DisposeAsync();
|
|
Assert.Equal(1, fake.DisposeCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_via_sync_path_disposes_client()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
((IDisposable)ds).Dispose();
|
|
Assert.Equal(1, fake.DisposeCallCount);
|
|
}
|
|
|
|
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
|
private static Timestamp Ts(int y, int mo, int d, int h, int mi, int s)
|
|
=> Timestamp.FromDateTime(new DateTime(y, mo, d, h, mi, s, DateTimeKind.Utc));
|
|
}
|