8c6d6aff6a
Moves the database-independent logic of the SQL Server historian backend into a new shared library for the upcoming QuestDB backend (#666): status codes and bits, value/column mapping, StoredSample + quality clamp, the AtTime and aggregate evaluators, BucketPlan, LiveWritePreparer and HistoryUpdatePlanner (both with a resolutionTicks parameter, default 1), tag-name rules, the bootstrap failure cache, health counters, alarm event ids, the event continuation and severity clamp. The SQL Server backend now calls it; its T-SQL, TVP builders, connection, bootstrapper and catalog stay. Tests: StatusCodeOracleTests, ValueWriterPrepareTests, the column half of SampleRowMapperTests and ProcessedReadTests.Every_enum_member_is_either_served_or_refused move to the new Sql.Common.Tests under the same names. SqlServer integration tests get mechanical renames only. Test-name set across the three projects is identical to the baseline (160 tests). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
106 lines
5.1 KiB
C#
106 lines
5.1 KiB
C#
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>
|
|
/// Final-review I-1: a stored sample is never served with a better quality than "no value". An Empty (null-value)
|
|
/// sample is always Bad — forced Bad by the value writer, refused non-Bad by the updater, and clamped to
|
|
/// <c>BadNoData</c> on read for rows written before either rule existed.
|
|
/// </summary>
|
|
[Collection(SqlHistorianCollection.Name)]
|
|
public sealed class QualityContractTests(SqlHistorianFixture fx)
|
|
{
|
|
private static readonly CancellationToken Ct = CancellationToken.None;
|
|
private static DateTime At(double s) => ReadTestDb.At(s);
|
|
|
|
private const uint BadOutOfRange = 0x803C0000u;
|
|
private const uint BadNoData = 0x809B0000u;
|
|
private const uint BadInvalidArgument = 0x80AB0000u;
|
|
private const uint BadNoCommunication = 0x80310000u;
|
|
|
|
private static bool IsBad(uint status) => (status & 0x80000000u) != 0;
|
|
|
|
private static SqlServerHistorianValueWriter Writer(ReadTestDb db) =>
|
|
new(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
|
|
|
private static SqlServerHistorianDataUpdater Updater(ReadTestDb db) =>
|
|
new(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
|
|
|
[Fact]
|
|
public async Task A_good_NaN_written_through_the_real_writer_reads_back_Bad_on_Raw_and_AtTime()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
(await Writer(db).WriteLiveValuesAsync("nan", [
|
|
new(At(0), HistorianValue.FromDouble(10), 0u),
|
|
new(At(10), HistorianValue.FromDouble(double.NaN), 0u),
|
|
new(At(20), HistorianValue.FromDouble(double.PositiveInfinity), 0x40000000u),
|
|
new(At(30), HistorianValue.FromDouble(double.NegativeInfinity), BadNoCommunication),
|
|
], Ct)).ShouldBeTrue();
|
|
|
|
var raw = (await db.Source().ReadRawAsync("nan", At(0), At(100), 0, Ct)).Samples;
|
|
raw.Count.ShouldBe(4);
|
|
raw[1].Value.ShouldBeNull();
|
|
raw[1].StatusCode.ShouldBe(BadOutOfRange); // Good NaN ⇒ stored Bad
|
|
raw[2].StatusCode.ShouldBe(BadOutOfRange); // Uncertain +Inf ⇒ stored Bad
|
|
raw[3].StatusCode.ShouldBe(BadNoCommunication); // an existing Bad code is kept
|
|
|
|
var at = (await db.Source().ReadAtTimeAsync("nan", [At(10), At(15)], Ct)).Samples;
|
|
at.ShouldAllBe(s => s.Value == null);
|
|
at.ShouldAllBe(s => IsBad(s.StatusCode));
|
|
at[1].StatusCode.ShouldBe(BadOutOfRange); // inside the "outage" the NaN opened
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_bad_Empty_written_through_the_real_writer_is_stored_BadNoData()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
(await Writer(db).WriteLiveValuesAsync("empty", [
|
|
new(At(0), HistorianValue.Empty, 0u),
|
|
new(At(1), HistorianValue.Empty, 0x40000000u),
|
|
new(At(2), HistorianValue.Empty, BadNoCommunication),
|
|
], Ct)).ShouldBeTrue();
|
|
|
|
// Read the stored column directly so the read-side clamp cannot mask a writer that forgot to force Bad.
|
|
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Sample")} WHERE StatusCode >= 0")).ShouldBe(0);
|
|
var raw = (await db.Source().ReadRawAsync("empty", At(0), At(10), 0, Ct)).Samples;
|
|
raw.Select(s => s.StatusCode).ShouldBe([BadNoData, BadNoData, BadNoCommunication]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_legacy_good_Empty_row_is_clamped_to_BadNoData_on_Raw_and_AtTime()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
// Seeded with plain INSERTs, bypassing the writer: what a pre-fix build could have stored.
|
|
await db.SeedAsync("legacy",
|
|
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
|
(At(10), HistorianValue.Empty, ReadTestDb.Good),
|
|
(At(20), HistorianValue.Empty, ReadTestDb.Uncertain),
|
|
(At(30), HistorianValue.FromDouble(40), ReadTestDb.Good));
|
|
|
|
var raw = (await db.Source().ReadRawAsync("legacy", At(0), At(100), 0, Ct)).Samples;
|
|
raw.Select(s => s.StatusCode).ShouldBe([ReadTestDb.Good, BadNoData, BadNoData, ReadTestDb.Good]);
|
|
|
|
var at = (await db.Source().ReadAtTimeAsync("legacy", [At(10), At(15), At(25)], Ct)).Samples;
|
|
at.ShouldAllBe(s => s.Value == null && s.StatusCode == BadNoData);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_updater_refuses_a_non_bad_Empty_and_accepts_a_bad_one()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
var r = await Updater(db).UpdateDataAsync("u", HistoryUpdateMode.Insert, [
|
|
new(At(0), HistorianValue.Empty, 0u),
|
|
new(At(1), HistorianValue.Empty, 0x40000000u),
|
|
new(At(2), HistorianValue.Empty, BadNoCommunication),
|
|
new(At(3), HistorianValue.FromDouble(1), 0u),
|
|
], Ct);
|
|
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
|
|
HistorianStatusCodes.GoodEntryInserted]);
|
|
(await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
|
|
.Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]);
|
|
}
|
|
}
|