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.Instance); var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var r = await ds.ReadRawAsync("T", start, start.AddMinutes(5), 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); // #687: asked from 1 ms before the window with room for AVEVA's boundary row (GatewayRawBoundaryRowTests). Assert.Equal(100 + RawAggregateReader.PreWindowAllowance, fake.LastReadRawMaxValues); } [Fact] public async Task ReadProcessed_uses_aggregate_mode_mapping() { var fake = new FakeHistorianGatewayClient(); var ds = new GatewayHistorianDataSource(fake, NullLogger.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.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.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.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.Instance); await Assert.ThrowsAsync(() => 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.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] // #690: an at-time NULL (histsdk: value 0 + Bad) is null + Bad, like the SQL backends' AtTime of a Bad sample public async Task ReadAtTime_of_a_NULL_row_carries_no_value() { var fake = new FakeHistorianGatewayClient(); var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); fake.AtTimeSamples = new[] { new HistorianSample { NumericValue = 0, OpcQuality = 0, Quality = 1, Timestamp = Timestamp.FromDateTime(t0) }, }; var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); var r = await ds.ReadAtTimeAsync("T", new[] { t0 }, TestContext.Current.CancellationToken); Assert.Null(r.Samples[0].Value); Assert.Equal(0x80000000u, r.Samples[0].StatusCode); } [Fact] public async Task Empty_window_is_not_a_fault() { var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty() }; var ds = new GatewayHistorianDataSource(fake, NullLogger.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.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.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)); }