Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeTimeWeightedGateTests.cs
T
Joseph Doherty 0b85c499e3 fix(historian): refuse native Gateway TimeAverage/Total until historiangw#27 (lmxopcua#746)
AVEVA's native TimeAverage / Total are answered with Good-status wrong values
while the gateway sends the interval in the wrong unit (historiangw#27): one
whole-span value on the earliest interval, the rest absent. Nothing on the
wire marks the answer as degraded.

Both are now refused BadAggregateNotSupported (unsent, not counted as a
backend failure, not advertised in AggregateFunctions) unless
ServerHistorian:Gateway:EnableNativeTimeWeighted is true. The default
Gateway set is the ten raw-evaluated aggregates
(GatewayHistorianDataSource.RawEvaluatedAggregates); ServedAggregates stays
the 12 the backend can serve with the flag on. The native-path tests opt in.

Also documents the ServerHistorian:Gateway sub-section (EventDrainBudget from
the previous commit) and the events drain in docs/Historian.md.

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:40:46 -05:00

86 lines
4.3 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// AVEVA's native TimeAverage / Total answer Good-status wrong values until the gateway's interval fix
/// (historiangw#27) is live-verified, so they are refused <c>BadAggregateNotSupported</c> unless
/// <c>ServerHistorian:Gateway:EnableNativeTimeWeighted</c> opts back in.
/// </summary>
public sealed class GatewayNativeTimeWeightedGateTests
{
private static readonly DateTime T0 = new(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.Total)]
public async Task By_default_the_native_time_weighted_pair_is_refused_unsent_and_unadvertised(HistoryAggregateType a)
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
ds.SupportedAggregates.ShouldNotContain(a);
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(
() => ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct));
fake.ReadAggregateCallCount.ShouldBe(0);
fake.TagExistsCallCount.ShouldBe(0);
ds.GetHealthSnapshot().TotalQueries.ShouldBe(0, "a refused aggregate is a client request, not a backend read");
// Positive control: a raw-evaluated per-sample aggregate on the same source is still served.
ds.SupportedAggregates.ShouldContain(HistoryAggregateType.Average);
var avg = await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(30), HistoryAggregateType.Average, Ct);
avg.Samples.Count.ShouldBe(2);
fake.ReadRawCallCount.ShouldBeGreaterThan(0);
}
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.Total)]
public async Task Opting_in_serves_and_advertises_the_native_pair(HistoryAggregateType a)
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.ServedAggregates);
await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct);
fake.ReadAggregateCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public void The_default_set_is_the_ten_raw_evaluated_aggregates()
{
var ds = new GatewayHistorianDataSource(new FakeHistorianGatewayClient(), NullLogger<GatewayHistorianDataSource>.Instance);
ds.SupportedAggregates.Count.ShouldBe(10);
ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.RawEvaluatedAggregates);
GatewayHistorianDataSource.ServedAggregates.Except(ds.SupportedAggregates).OrderBy(x => x)
.ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.TimeAverage]);
}
[Theory]
[InlineData(null, false)]
[InlineData("false", false)]
[InlineData("true", true)]
public void The_flag_binds_from_configuration_and_reaches_the_data_source(string? configured, bool expected)
{
var values = new Dictionary<string, string?>
{
["ServerHistorian:Enabled"] = "true",
["ServerHistorian:Endpoint"] = "https://localhost:5222",
["ServerHistorian:ApiKey"] = "histgw_x_y",
};
if (configured is not null) values["ServerHistorian:Gateway:EnableNativeTimeWeighted"] = configured;
var opts = new ConfigurationBuilder().AddInMemoryCollection(values).Build()
.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>()!;
using var services = new ServiceCollection().BuildServiceProvider();
var ds = Assert.IsType<GatewayHistorianDataSource>(GatewayHistorian.CreateDataSource(opts, services));
ds.SupportedAggregates.Contains(HistoryAggregateType.TimeAverage).ShouldBe(expected);
}
}