e7b485c0a2
#820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the same Good / Bad region totals TimeWeightedAggregator already computes for PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status / Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the up-front refusal follow automatically; Gateway still refuses both (explicit served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both. The #796 oracle answers DurationGood through the Good-only subclass as well. QualityMix conformance asserts both directly (Q and QP, hand values, ms, and = PercentGood x interval), replaces the "refused up front" test, and adds Duration* to the forward/reverse/truncated oracle comparisons. #821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first late-start interval (QP over [0,20), both directions) against ours (Part 13 Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96, 9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control (all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted server's runtime AggregateManager (live AggregateFilter) incl. DurationGood 15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix adds the reverse late-start variant (reverse TW pin + reverse oracle windows). Co-Authored-By: Claude <noreply@anthropic.com>
178 lines
9.0 KiB
C#
178 lines
9.0 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
|
|
|
public sealed class HistorianBackendRegistryTests
|
|
{
|
|
private static readonly IConfiguration Cfg = new ConfigurationBuilder().Build();
|
|
|
|
private static ServerHistorianOptions Gateway(string backend = "Gateway") => new()
|
|
{
|
|
Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
};
|
|
|
|
[Fact]
|
|
public void Default_backend_is_Gateway_and_resolves_the_gateway_provider()
|
|
{
|
|
new ServerHistorianOptions().Backend.ShouldBe("Gateway");
|
|
HistorianBackendRegistry.Resolve(new ServerHistorianOptions(), Cfg).ShouldBeOfType<GatewayHistorianProvider>();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("gateway")]
|
|
[InlineData("GATEWAY")]
|
|
[InlineData("Gateway")]
|
|
public void Resolve_is_case_insensitive(string name) =>
|
|
HistorianBackendRegistry.Resolve(Gateway(name), Cfg).Name.ShouldBe("Gateway");
|
|
|
|
[Fact]
|
|
public void Unknown_backend_throws_naming_the_value_and_the_allowed_names()
|
|
{
|
|
var ex = Should.Throw<OptionsValidationException>(
|
|
() => HistorianBackendRegistry.Resolve(Gateway("Nope"), Cfg));
|
|
var msg = string.Join(" ", ex.Failures);
|
|
msg.ShouldContain("Nope");
|
|
msg.ShouldContain("Gateway");
|
|
msg.ShouldContain("ServerHistorian:Backend");
|
|
}
|
|
|
|
[Fact]
|
|
public void KnownBackends_lists_Gateway() => HistorianBackendRegistry.KnownBackends.ShouldContain("Gateway");
|
|
|
|
[Fact]
|
|
public void Gateway_provider_builds_the_same_concrete_seams_Program_used_to_build()
|
|
{
|
|
var sp = new ServiceCollection().BuildServiceProvider();
|
|
var p = HistorianBackendRegistry.Resolve(Gateway(), Cfg);
|
|
|
|
p.CreateDataSource(sp).ShouldBeOfType<GatewayHistorianDataSource>();
|
|
p.CreateValueWriter(sp).ShouldBeOfType<GatewayHistorianValueWriter>();
|
|
p.CreateAlarmWriter(sp).ShouldBeOfType<GatewayAlarmHistorianWriter>();
|
|
p.CreateProvisioning(sp).ShouldBeOfType<GatewayTagProvisioner>();
|
|
p.CreateUpdater(sp).ShouldBeNull();
|
|
}
|
|
|
|
private static IConfiguration SqlCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
|
|
}).Build();
|
|
|
|
[Fact]
|
|
public void SqlServer_backend_resolves_and_builds_the_sql_seams()
|
|
{
|
|
var sp = new ServiceCollection().BuildServiceProvider();
|
|
var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "sqlserver", Enabled = true }, SqlCfg());
|
|
|
|
p.ShouldBeOfType<SqlServerHistorianProvider>();
|
|
HistorianBackendRegistry.KnownBackends.ShouldContain("SqlServer");
|
|
p.SupportedKinds.Count.ShouldBe(6);
|
|
p.SupportedKinds.ShouldContain(HistorianValueKind.String);
|
|
p.SupportedKinds.ShouldContain(HistorianValueKind.Empty);
|
|
p.CreateDataSource(sp).ShouldBeOfType<SqlServerHistorianDataSource>();
|
|
p.CreateValueWriter(sp).GetType().Name.ShouldBe("SqlServerHistorianValueWriter");
|
|
p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("SqlServerAlarmHistorianWriter");
|
|
p.CreateProvisioning(sp).GetType().Name.ShouldBe("SqlServerTagProvisioner");
|
|
var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp));
|
|
holder.Updater.ShouldNotBeNull();
|
|
holder.Updater!.GetType().Name.ShouldBe("SqlServerHistorianDataUpdater");
|
|
}
|
|
|
|
private static IConfiguration QuestDbCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb",
|
|
["ServerHistorian:QuestDb:TablePrefix"] = "rig_",
|
|
}).Build();
|
|
|
|
[Theory]
|
|
[InlineData("questdb")]
|
|
[InlineData("QUESTDB")]
|
|
[InlineData("QuestDb")]
|
|
public void QuestDb_backend_resolves_case_insensitively(string name) =>
|
|
HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = name, Enabled = true }, QuestDbCfg())
|
|
.ShouldBeOfType<QuestDbHistorianProvider>().Name.ShouldBe("QuestDb");
|
|
|
|
[Fact]
|
|
public void QuestDb_backend_resolves_and_builds_the_questdb_seams()
|
|
{
|
|
var sp = new ServiceCollection().BuildServiceProvider();
|
|
var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "questdb", Enabled = true }, QuestDbCfg());
|
|
|
|
p.ShouldBeOfType<QuestDbHistorianProvider>();
|
|
HistorianBackendRegistry.KnownBackends.ShouldContain("QuestDb");
|
|
p.SupportedKinds.Count.ShouldBe(6);
|
|
p.SupportedKinds.ShouldContain(HistorianValueKind.String);
|
|
p.SupportedKinds.ShouldContain(HistorianValueKind.Empty);
|
|
p.CreateDataSource(sp).ShouldBeOfType<QuestDbHistorianDataSource>();
|
|
p.CreateValueWriter(sp).GetType().Name.ShouldBe("QuestDbHistorianValueWriter");
|
|
p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("QuestDbAlarmHistorianWriter");
|
|
p.CreateProvisioning(sp).GetType().Name.ShouldBe("QuestDbTagProvisioner");
|
|
var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp));
|
|
holder.Updater.ShouldNotBeNull();
|
|
holder.Updater!.GetType().Name.ShouldBe("QuestDbHistorianDataUpdater");
|
|
}
|
|
|
|
[Fact]
|
|
public void BindQuestDb_reads_the_QuestDb_section()
|
|
{
|
|
var o = HistorianBackendRegistry.BindQuestDb(QuestDbCfg());
|
|
o.ConnectionString.ShouldContain("Port=18812");
|
|
o.TablePrefix.ShouldBe("rig_");
|
|
}
|
|
|
|
/// <summary>The nineteen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the
|
|
/// family table or a backend's declaration fails here (#682).</summary>
|
|
private static readonly HistoryAggregateType[] SqlServed =
|
|
[
|
|
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum, HistoryAggregateType.Count,
|
|
HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
|
|
HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime, HistoryAggregateType.TimeAverage,
|
|
HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2,
|
|
HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
|
|
HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad,
|
|
];
|
|
|
|
[Fact]
|
|
public void Each_backend_data_source_declares_the_aggregates_it_serves()
|
|
{
|
|
// #682: what the server advertises under AggregateFunctions comes from these — resolved exactly as the Host does.
|
|
var sp = new ServiceCollection().BuildServiceProvider();
|
|
IHistorianDataSource Source(ServerHistorianOptions o, IConfiguration c) =>
|
|
HistorianBackendRegistry.Resolve(o, c).CreateDataSource(sp);
|
|
|
|
// #681: the ten per-sample aggregates (raw-evaluated). The two AVEVA-native ones (TimeAverage / Total) are
|
|
// refused and not advertised by default until historiangw#27 is fixed (#746, R08:2.1) ...
|
|
HistoryAggregateType[] gatewayRawEvaluated =
|
|
[
|
|
HistoryAggregateType.Average, HistoryAggregateType.Count, HistoryAggregateType.Minimum,
|
|
HistoryAggregateType.Maximum, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
|
|
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
|
];
|
|
Source(Gateway(), Cfg).SupportedAggregates.ShouldBe(gatewayRawEvaluated, ignoreOrder: true);
|
|
|
|
// ... and served again, through the Host's resolve path, once ServerHistorian:Gateway:EnableNativeTimeWeighted is on.
|
|
var nativeOn = new ServerHistorianOptions
|
|
{
|
|
Backend = "Gateway", Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
|
Gateway = new GatewayHistorianSettings { EnableNativeTimeWeighted = true },
|
|
};
|
|
Source(nativeOn, Cfg).SupportedAggregates.ShouldBe(
|
|
[.. gatewayRawEvaluated, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total], ignoreOrder: true);
|
|
Source(new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }, SqlCfg())
|
|
.SupportedAggregates.ShouldBe(SqlServed, ignoreOrder: true);
|
|
Source(new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }, QuestDbCfg())
|
|
.SupportedAggregates.ShouldBe(SqlServed, ignoreOrder: true);
|
|
NullHistorianDataSource.Instance.SupportedAggregates.ShouldBeEmpty();
|
|
}
|
|
}
|