Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ReadTestDb.cs
T
Joseph Doherty 56bb4855bb fix(historian): #671 review — raw-row cap, reverse Interpolative, SDK status rule, Partial bit
M1: the time-weighted family (incl. Total, formerly a pushed-down SUM) streams
raw window rows into the C# evaluator. New shared knob
ServerHistorian:MaxTimeWeightedRawRows (default 1,000,000; <=0 unbounded with a
Validate warning) flows through HistorianBackendRegistry into both providers.
The window query is capped at cap+1 (SQL Server TOP (@maxRows), QuestDB LIMIT $4);
one row past the cap throws the new Core.Abstractions
HistoryReadLimitExceededException, which the node manager maps to
BadTooManyOperations for that node. Not counted as a backend health failure;
bound rows are not counted; per-sample aggregates are not capped.

M2: reverse Interpolative is anchored at the later time (Part 13): points at
End - i*interval, emitted latest first. 10:00->09:00 @1h yields 10:00.

L1: Partial bit (0x4, = Opc.Ua.AggregateBits.Partial) on non-Bad results of a
truncated last interval (time-weighted family; per-sample family is a follow-up).

L2/L3: aligned with the SDK AggregateCalculator.GetTimeBasedStatusCode
(decompiled from Opc.Ua.Server 1.5.378.106): TreatUncertainAsBad=false counts
Uncertain time (Uncertain samples, held-into-outage, extrapolation) as Good
time; data but no non-Bad time => generic Bad (0x80000000); BadNoData only
when there is no data at all. Consequence: a live tag's latest interval reads
Good (documented).

L5: release note no longer claims Total matches the Gateway Integral exactly
(AVEVA's divisor is unverified); it notes Total is now evaluated in C# and
subject to the cap.

Tests: unit (reverse Interpolative x3, Partial, Bad vs BadNoData incl. an
open-ended outage, cap helpers, SDK bit oracles); conformance (reverse
Interpolative, raw-row cap at/over/0, bounds not counted, health unaffected);
node manager BadTooManyOperations; options default + warning. Each new
behaviour falsified by mutation (aggregator x9, data-source cap checks on
both backends against the real DBs, node-manager catch).
2026-10-11 09:10:26 -04:00

89 lines
4.4 KiB
C#

using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>
/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it.
/// Shared by the SQL Server-specific tests and by <see cref="SqlServerConformanceBackend"/>. <see cref="T0"/> is deliberately NOT millisecond-aligned.
/// </summary>
internal sealed class ReadTestDb
{
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
public static readonly DateTime T0 = Conformance.ConformanceData.T0;
public const uint Good = Conformance.ConformanceData.Good;
public const uint Bad = Conformance.ConformanceData.Bad;
public const uint BadNoCommunication = Conformance.ConformanceData.BadNoCommunication;
public const uint Uncertain = Conformance.ConformanceData.Uncertain;
private ReadTestDb(SqlServerHistorianOptions options, SqlHistorianConnection conn, SqlServerSchemaBootstrapper boot)
{
Options = options;
Conn = conn;
Boot = boot;
}
public SqlServerHistorianOptions Options { get; }
public SqlHistorianConnection Conn { get; }
public SqlServerSchemaBootstrapper Boot { get; }
public static DateTime At(double seconds) => Conformance.ConformanceData.At(seconds);
public static async Task<ReadTestDb> CreateAsync(SqlHistorianFixture fx)
{
fx.SkipIfUnavailable();
var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema() };
var conn = new SqlHistorianConnection(o);
var boot = new SqlServerSchemaBootstrapper(conn, o);
await boot.EnsureAsync(CancellationToken.None);
return new ReadTestDb(o, conn, boot);
}
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
public SqlServerHistorianDataSource Source(TimeProvider? clock = null,
int maxTimeWeightedRawRows = TimeWeightedAggregator.DefaultMaxWindowRows) =>
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock, maxTimeWeightedRawRows: maxTimeWeightedRawRows);
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
int id;
await using (var cmd = Conn.CreateCommand(c,
$"INSERT {Conn.Q("Tag")} (Name, DataType, CreatedUtc, UpdatedUtc) OUTPUT inserted.TagId VALUES (@n, 0, SYSUTCDATETIME(), SYSUTCDATETIME())"))
{
cmd.Parameters.AddWithValue("@n", tag);
id = (int)(await cmd.ExecuteScalarAsync())!;
}
foreach (var (t, v, status) in samples)
{
var col = SampleColumnMapper.ToColumns(v);
await using var cmd = Conn.CreateCommand(c,
$"INSERT {Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
"VALUES (@id, @t, @k, @num, @int, @str, @st, @srv)");
cmd.Parameters.AddWithValue("@id", id);
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
cmd.Parameters.AddWithValue("@k", col.ValueKind);
cmd.Parameters.Add(new SqlParameter("@num", System.Data.SqlDbType.Float) { Value = (object?)col.NumValue ?? DBNull.Value });
cmd.Parameters.Add(new SqlParameter("@int", System.Data.SqlDbType.BigInt) { Value = (object?)col.IntValue ?? DBNull.Value });
cmd.Parameters.Add(new SqlParameter("@str", System.Data.SqlDbType.NVarChar, -1) { Value = (object?)col.StrValue ?? DBNull.Value });
cmd.Parameters.AddWithValue("@st", SampleColumnMapper.ToDbStatus(status));
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
await cmd.ExecuteNonQueryAsync();
}
return id;
}
public async Task<long> ScalarAsync(string sql)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
await using var cmd = Conn.CreateCommand(c, sql);
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
}