using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; /// /// 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 /// BadNoData on read for rows written before either rule existed. /// [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, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]); (await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples .Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]); } }