diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs b/tests/Drivers/Historian.Conformance/AtTimeConformance.cs similarity index 59% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs rename to tests/Drivers/Historian.Conformance/AtTimeConformance.cs index 110beb80..8a2acea7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs +++ b/tests/Drivers/Historian.Conformance/AtTimeConformance.cs @@ -2,18 +2,16 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// AtTime rules (spec §4, as amended after the T10 review): exact, linear, stepped, no-prior, and the two /// Bad-neighbour cases — inside an outage (Bad previous) ⇒ null with the outage's own Bad code; before an /// outage (usable previous, Bad next) ⇒ previous value held, Uncertain. Never bridges. /// -[Collection(SqlHistorianCollection.Name)] -public sealed class AtTimeReadTests(SqlHistorianFixture fx) +public abstract class AtTimeConformance : ConformanceBase { private static readonly CancellationToken Ct = CancellationToken.None; - private static DateTime At(double s) => ReadTestDb.At(s); // Hand-computed status literals: Interpolated = InfoType DataValue 0x400 | Interpolated 0x2. private const uint GoodInterpolated = 0x00000402u; @@ -33,13 +31,12 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task Exact_hit_returns_the_stored_value_and_status() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(10), At(20)], Ct)).Samples; + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(10), At(20)], Ct)).Samples; s[0].Value.ShouldBe(20d); s[0].StatusCode.ShouldBe(0u); - s[0].SourceTimestampUtc.ShouldBe(At(10)); - s[0].ServerTimestampUtc.ShouldBe(At(11)); // the stored ServerTimeUtc + s[0].SourceTimestampUtc.ShouldBe(B.Round(At(10))); + s[0].ServerTimestampUtc.ShouldBe(B.Round(At(11))); // the stored ServerTimeUtc s[1].Value.ShouldBe(30d); // a stored Bad sample comes back as stored s[1].StatusCode.ShouldBe(0x80000000u); } @@ -47,33 +44,31 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task Between_two_good_doubles_is_linearly_interpolated_good() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(2.5), At(32.5)], Ct)).Samples; + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(5), At(2.5), At(32.5)], Ct)).Samples; s[0].Value.ShouldBe(15d); // 10 + (20-10) * 5/10 s[1].Value.ShouldBe(12.5d); // 10 + 10 * 2.5/10 s[2].Value.ShouldBe(42.5d); // 40 + 10 * 2.5/10 s.ShouldAllBe(x => x.StatusCode == GoodInterpolated); - s[0].SourceTimestampUtc.ShouldBe(At(5)); // stamped at the requested (sub-ms) time + s[0].SourceTimestampUtc.ShouldBe(B.Round(At(5))); // stamped at the requested (sub-ms) time } [Fact] public async Task No_previous_sample_is_BadNoData() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(0).AddTicks(-1)], Ct)).Samples.Single(); + await ConformanceData.SeedAAsync(B); + var before = At(0).AddTicks(-B.ResolutionTicks); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [before], Ct)).Samples.Single(); s.Value.ShouldBeNull(); s.StatusCode.ShouldBe(BadNoData); - s.SourceTimestampUtc.ShouldBe(At(0).AddTicks(-1)); + s.SourceTimestampUtc.ShouldBe(B.Round(before)); } [Fact] public async Task A_bad_next_neighbour_holds_the_previous_value_uncertain() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(15)], Ct)).Samples.Single(); + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(15)], Ct)).Samples.Single(); s.Value.ShouldBe(20d); // NOT 25 (interpolating toward the Bad 30) s.StatusCode.ShouldBe(UncertainInterpolated); } @@ -81,23 +76,21 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task A_bad_previous_neighbour_is_inside_an_outage_and_keeps_its_bad_code() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single(); + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single(); s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value) s.StatusCode.ShouldBe(0x80000000u); // the previous sample's Bad code — never upgraded to Uncertain - s.SourceTimestampUtc.ShouldBe(At(25)); + s.SourceTimestampUtc.ShouldBe(B.Round(At(25))); } [Fact] public async Task Interpolation_never_bridges_an_outage_marker() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("M", - (At(0), HistorianValue.FromDouble(10), ReadTestDb.Good), - (At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication), - (At(10), HistorianValue.FromDouble(30), ReadTestDb.Good)); - var s = (await db.Source().ReadAtTimeAsync("M", [At(2.5), At(7.5), At(5)], Ct)).Samples; + await B.SeedAsync("M", + (At(0), HistorianValue.FromDouble(10), Good), + (At(5), HistorianValue.Empty, BadNoCommunication), + (At(10), HistorianValue.FromDouble(30), Good)); + var s = (await B.CreateDataSource().ReadAtTimeAsync("M", [At(2.5), At(7.5), At(5)], Ct)).Samples; s[0].Value.ShouldBe(10d); // bridging 10→30 would give 15 s[0].StatusCode.ShouldBe(UncertainInterpolated); s[1].Value.ShouldBeNull(); // bridging would give 25 @@ -109,9 +102,8 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task After_the_last_sample_the_value_is_stepped() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(45)], Ct)).Samples.Single(); + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(45)], Ct)).Samples.Single(); s.Value.ShouldBe(50d); s.StatusCode.ShouldBe(GoodInterpolated); } @@ -119,9 +111,8 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task A_time_after_now_stepped_from_the_last_sample_is_uncertain() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); // last sample +40 = 50, Good - var src = db.Source(new FixedClock(new DateTimeOffset(At(42)))); + await ConformanceData.SeedAAsync(B); // last sample +40 = 50, Good + var src = B.CreateDataSource(new ConformanceClock(new DateTimeOffset(At(42)))); var s = (await src.ReadAtTimeAsync("A", [At(41), At(45)], Ct)).Samples; s[0].Value.ShouldBe(50d); s[0].StatusCode.ShouldBe(GoodInterpolated); // after the last sample but not after now: held Good @@ -132,11 +123,10 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("U", - (At(0), HistorianValue.FromDouble(10), ReadTestDb.Uncertain), - (At(10), HistorianValue.FromDouble(20), ReadTestDb.Good)); - var s = (await db.Source().ReadAtTimeAsync("U", [At(5)], Ct)).Samples.Single(); + await B.SeedAsync("U", + (At(0), HistorianValue.FromDouble(10), Uncertain), + (At(10), HistorianValue.FromDouble(20), Good)); + var s = (await B.CreateDataSource().ReadAtTimeAsync("U", [At(5)], Ct)).Samples.Single(); s.Value.ShouldBe(15d); s.StatusCode.ShouldBe(UncertainInterpolated); } @@ -144,11 +134,10 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task Int64_is_interpolated_and_rounded_keeping_its_type() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("I", - (At(0), HistorianValue.FromInt64(10), ReadTestDb.Good), - (At(10), HistorianValue.FromInt64(15), ReadTestDb.Good)); - var s = (await db.Source().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples; + await B.SeedAsync("I", + (At(0), HistorianValue.FromInt64(10), Good), + (At(10), HistorianValue.FromInt64(15), Good)); + var s = (await B.CreateDataSource().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples; s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero) s[1].Value.ShouldBe(11L); // 11.0 s[0].StatusCode.ShouldBe(GoodInterpolated); @@ -157,18 +146,17 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task Boolean_string_and_datetime_are_stepped_from_the_previous_sample() { - var db = await ReadTestDb.CreateAsync(fx); - var d0 = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(3); - await db.SeedAsync("B", - (At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good), - (At(10), HistorianValue.FromBoolean(false), ReadTestDb.Good)); - await db.SeedAsync("S", - (At(0), HistorianValue.FromString("alpha"), ReadTestDb.Uncertain), - (At(10), HistorianValue.FromString("beta"), ReadTestDb.Good)); - await db.SeedAsync("D", - (At(0), HistorianValue.FromDateTime(d0), ReadTestDb.Good), - (At(10), HistorianValue.FromDateTime(d0.AddDays(1)), ReadTestDb.Good)); - var src = db.Source(); + var d0 = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(3 * B.ResolutionTicks); + await B.SeedAsync("B", + (At(0), HistorianValue.FromBoolean(true), Good), + (At(10), HistorianValue.FromBoolean(false), Good)); + await B.SeedAsync("S", + (At(0), HistorianValue.FromString("alpha"), Uncertain), + (At(10), HistorianValue.FromString("beta"), Good)); + await B.SeedAsync("D", + (At(0), HistorianValue.FromDateTime(d0), Good), + (At(10), HistorianValue.FromDateTime(d0.AddDays(1)), Good)); + var src = B.CreateDataSource(); var b = (await src.ReadAtTimeAsync("B", [At(9)], Ct)).Samples.Single(); b.Value.ShouldBe(true); b.StatusCode.ShouldBe(GoodInterpolated); @@ -181,19 +169,17 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) [Fact] public async Task Result_matches_request_length_and_order_including_duplicates() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(-1), At(5), At(10)], Ct)).Samples; + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(5), At(-1), At(5), At(10)], Ct)).Samples; s.Count.ShouldBe(4); s.Select(x => x.Value).ShouldBe([15d, null, 15d, 20d]); - s.Select(x => x.SourceTimestampUtc).ShouldBe([At(5), At(-1), At(5), At(10)]); + s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(5)), B.Round(At(-1)), B.Round(At(5)), B.Round(At(10))]); } [Fact] public async Task Empty_request_and_unknown_tag() { - var db = await ReadTestDb.CreateAsync(fx); - var src = db.Source(); + var src = B.CreateDataSource(); (await src.ReadAtTimeAsync("A", [], Ct)).Samples.ShouldBeEmpty(); var s = (await src.ReadAtTimeAsync("nope", [At(1), At(2)], Ct)).Samples; s.Count.ShouldBe(2); diff --git a/tests/Drivers/Historian.Conformance/ConformanceData.cs b/tests/Drivers/Historian.Conformance/ConformanceData.cs new file mode 100644 index 00000000..ac08471d --- /dev/null +++ b/tests/Drivers/Historian.Conformance/ConformanceData.cs @@ -0,0 +1,40 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; + +/// Shared fixture data of the conformance suite. is deliberately NOT millisecond- or microsecond-aligned. +public static class ConformanceData +{ + /// Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose). + public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567); + + /// Good. + public const uint Good = 0x00000000u; + /// Bad (generic). + public const uint Bad = 0x80000000u; + /// BadNoCommunication. + public const uint BadNoCommunication = 0x80310000u; + /// Uncertain (generic). + public const uint Uncertain = 0x40000000u; + + /// T0 + seconds. + public static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond)); + + /// The fixture tag A: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double). + public static Task SeedAAsync(IConformanceBackend b, string name = "A") => b.SeedAsync(name, + (At(0), HistorianValue.FromDouble(10), Good), + (At(10), HistorianValue.FromDouble(20), Good), + (At(20), HistorianValue.FromDouble(30), Bad), + (At(30), HistorianValue.FromDouble(40), Good), + (At(40), HistorianValue.FromDouble(50), Good)); +} + +/// A settable clock for a data source's "now". +public sealed class ConformanceClock(DateTimeOffset now) : TimeProvider +{ + /// The current instant. + public DateTimeOffset Now { get; set; } = now; + + /// + public override DateTimeOffset GetUtcNow() => Now; +} diff --git a/tests/Drivers/Historian.Conformance/IConformanceBackend.cs b/tests/Drivers/Historian.Conformance/IConformanceBackend.cs new file mode 100644 index 00000000..55077224 --- /dev/null +++ b/tests/Drivers/Historian.Conformance/IConformanceBackend.cs @@ -0,0 +1,89 @@ +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; + +/// +/// One historian backend under test. The conformance suite talks to a backend only through this contract +/// (plus Core.Abstractions), so the same hand-computed expectations run against SQL Server and QuestDB. +/// Each instance owns an isolated store (a fresh schema / table set) so tests never see each other's data. +/// +public interface IConformanceBackend : IAsyncDisposable +{ + /// Storage timestamp resolution in .NET ticks: 1 for SQL Server (100 ns), 10 for QuestDB (µs). + long ResolutionTicks { get; } + + /// Truncates a timestamp to the backend's storage resolution (UTC kind). Every timestamp assertion goes through it. + /// The timestamp. + /// The value the backend stores/serves for . + DateTime Round(DateTime t) => new(t.Ticks - t.Ticks % ResolutionTicks, DateTimeKind.Utc); + + /// A fresh data source (fresh catalog cache) over this backend's store. + /// Its "now"; null means the system clock. + IHistorianDataSource CreateDataSource(TimeProvider? clock = null); + + /// A fresh live-value writer over this backend's store. + /// The clock used to stamp values that carry no timestamp; null means the system clock. + IHistorianValueWriter CreateValueWriter(Func? utcNow = null); + + /// A fresh history updater over this backend's store. + IHistorianDataUpdater CreateUpdater(); + + /// + /// Writes rows directly in the backend's schema (never through the writer or updater), status verbatim, creating the + /// tag if needed. The stored server timestamp of each row is its source time + 1 s, so tests can tell them apart. + /// + /// The tag name. + /// The rows to store. + Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples); + + /// + /// Live (non-deleted) rows for a tag, ascending by time, read directly from storage. The status is returned + /// VERBATIM as stored — there is no read-side clamp (an Empty row stored non-Bad reads back non-Bad here). + /// + /// The tag name. + Task> ReadBackAsync(string tag); + + /// Whether a tag with exactly this name exists in storage (no truncation of long names). + /// The tag name. + Task TagExistsAsync(string tag); + + /// The data type the backend recorded for a tag, or null when the tag does not exist. + /// The tag name. + Task TagDataTypeAsync(string tag); +} + +/// +/// Base of every conformance class. A backend's test project subclasses each abstract suite class once, +/// supplying , which must call its fixture's skip gate so the tests report Skipped +/// (not Failed) when the backend is unavailable. +/// +public abstract class ConformanceBase : IAsyncLifetime +{ + /// Good. + protected const uint Good = ConformanceData.Good; + /// Bad (generic). + protected const uint Bad = ConformanceData.Bad; + /// BadNoCommunication. + protected const uint BadNoCommunication = ConformanceData.BadNoCommunication; + /// Uncertain (generic). + protected const uint Uncertain = ConformanceData.Uncertain; + + /// The backend under test for the current test. + protected IConformanceBackend B { get; private set; } = null!; + + /// Creates this test's isolated backend; calls the fixture's skip gate first. + protected abstract Task CreateBackendAsync(); + + /// T0 + seconds. + protected static DateTime At(double s) => ConformanceData.At(s); + + /// + public async ValueTask InitializeAsync() => B = await CreateBackendAsync(); + + /// + public async ValueTask DisposeAsync() + { + if (B is not null) await B.DisposeAsync(); + } +} diff --git a/tests/Drivers/Historian.Conformance/LiveWriteConformance.cs b/tests/Drivers/Historian.Conformance/LiveWriteConformance.cs new file mode 100644 index 00000000..c6960e19 --- /dev/null +++ b/tests/Drivers/Historian.Conformance/LiveWriteConformance.cs @@ -0,0 +1,97 @@ +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.Conformance; + +/// Live value writer behaviour: round-trips, idempotent redelivery, first-wins duplicates, non-finite doubles. +public abstract class LiveWriteConformance : ConformanceBase +{ + // Non-ms-aligned: sub-millisecond ticks must round-trip exactly (to the backend's resolution). + private static readonly DateTime T0 = new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc).AddTicks(1234567); + + [Fact] + public async Task Every_kind_round_trips_with_exact_ticks_and_status() + { + var w = B.CreateValueWriter(); + var r = B.ResolutionTicks; + var dt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(77 * r); + HistorizationValue[] vals = + [ + new(T0, HistorianValue.FromDouble(1.25), 0), + new(T0.AddTicks(1 * r), HistorianValue.FromInt64(long.MaxValue), 0x40000000u), + new(T0.AddTicks(2 * r), HistorianValue.FromBoolean(true), 0), + new(T0.AddTicks(3 * r), HistorianValue.FromString("héllo"), 0), + new(T0.AddTicks(4 * r), HistorianValue.FromDateTime(dt), 0), + new(T0.AddTicks(5 * r), HistorianValue.Empty, 0x80310000u), + ]; + (await w.WriteLiveValuesAsync("tag.a", vals, CancellationToken.None)).ShouldBeTrue(); + var rows = await B.ReadBackAsync("tag.a"); + rows.Count.ShouldBe(6); + for (var i = 0; i < 6; i++) rows[i].T.Ticks.ShouldBe(B.Round(vals[i].TimestampUtc!.Value).Ticks); + rows.Select(x => x.V).ToList().ShouldBe(vals.Select(v => v.Value).ToList()); + rows[1].Status.ShouldBe(0x40000000u); + rows[5].Status.ShouldBe(0x80310000u); + } + + [Fact] + public async Task Writing_the_same_batch_twice_is_idempotent() + { + var w = B.CreateValueWriter(); + HistorizationValue[] vals = [new(T0, HistorianValue.FromDouble(1), 0), new(T0.AddSeconds(1), HistorianValue.FromDouble(2), 0)]; + (await w.WriteLiveValuesAsync("tag.b", vals, CancellationToken.None)).ShouldBeTrue(); + (await w.WriteLiveValuesAsync("tag.b", vals, CancellationToken.None)).ShouldBeTrue(); + // Overlapping batch: only the new row lands, the stored first value is untouched. + (await w.WriteLiveValuesAsync("tag.b", [new(T0, HistorianValue.FromDouble(99), 0), new(T0.AddSeconds(2), HistorianValue.FromDouble(3), 0)], CancellationToken.None)).ShouldBeTrue(); + var rows = await B.ReadBackAsync("tag.b"); + rows.Count.ShouldBe(3); + rows[0].V.Number.ShouldBe(1); + } + + [Fact] + public async Task Within_batch_duplicate_timestamps_do_not_fail_and_first_wins() + { + var w = B.CreateValueWriter(); + (await w.WriteLiveValuesAsync("tag.c", [new(T0, HistorianValue.FromDouble(1), 0), new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); + var rows = await B.ReadBackAsync("tag.c"); + rows.Count.ShouldBe(1); + rows[0].V.Number.ShouldBe(1); + } + + [Fact] + public async Task Non_finite_doubles_store_as_Empty_forced_Bad_and_do_not_fail_the_write() + { + var w = B.CreateValueWriter(); + var r = B.ResolutionTicks; + (await w.WriteLiveValuesAsync("tag.d", [ + new(T0, HistorianValue.FromDouble(double.NaN), 0x40000000u), + new(T0.AddTicks(1 * r), HistorianValue.FromDouble(double.PositiveInfinity), 0), + new(T0.AddTicks(2 * r), HistorianValue.FromDouble(7), 0)], CancellationToken.None)).ShouldBeTrue(); + var rows = await B.ReadBackAsync("tag.d"); + rows.Count.ShouldBe(3); + rows[0].V.Kind.ShouldBe(HistorianValueKind.Empty); + rows[0].Status.ShouldBe(HistorianStatusCodes.BadOutOfRange); // Uncertain NaN ⇒ Bad (I-1) + rows[1].Status.ShouldBe(HistorianStatusCodes.BadOutOfRange); // Good +Inf ⇒ Bad (I-1) + rows[2].V.Number.ShouldBe(7); + } + + [Fact] + public async Task Empty_batch_succeeds_without_a_call_and_null_timestamp_is_stamped() + { + var w = B.CreateValueWriter(); + (await w.WriteLiveValuesAsync("tag.e", [], CancellationToken.None)).ShouldBeTrue(); + (await w.WriteLiveValuesAsync("tag.e", [new(null, HistorianValue.FromInt64(5), 0)], CancellationToken.None)).ShouldBeTrue(); + var rows = await B.ReadBackAsync("tag.e"); + rows.Count.ShouldBe(1); + (DateTime.UtcNow - rows[0].T).ShouldBeLessThan(TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task Tag_is_created_with_the_first_typed_values_data_type() + { + var w = B.CreateValueWriter(); + await w.WriteLiveValuesAsync("tag.f", [new(T0, HistorianValue.Empty, 0x80310000u), new(T0.AddTicks(B.ResolutionTicks), HistorianValue.FromString("x"), 0)], CancellationToken.None); + (await B.TagDataTypeAsync("tag.f")).ShouldBe(DriverDataType.String); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs similarity index 66% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs rename to tests/Drivers/Historian.Conformance/ProcessedConformance.cs index bc98a74c..cd579922 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs +++ b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs @@ -2,20 +2,18 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// Push-down aggregates over tag A (+0=10, +10=20, +20=Bad(30), +30=40, +40=50), 20 s intervals from the -/// sub-millisecond , window [t0, t0+80s) ⇒ four buckets: +/// sub-millisecond , window [t0, t0+80s) ⇒ four buckets: /// B0 [0,20) = {10, 20}; B1 [20,40) = {Bad 30, 40}; B2 [40,60) = {50}; B3 [60,80) = {} (empty). /// Every expected value below is hand-computed. /// -[Collection(SqlHistorianCollection.Name)] -public sealed class ProcessedReadTests(SqlHistorianFixture fx) +public abstract class ProcessedConformance : ConformanceBase { private static readonly CancellationToken Ct = CancellationToken.None; private static readonly TimeSpan Twenty = TimeSpan.FromSeconds(20); - private static DateTime At(double s) => ReadTestDb.At(s); // Calculated = InfoType DataValue 0x400 | Calculated 0x1, OR-ed onto the code. private const uint GoodCalc = 0x00000401u; @@ -23,8 +21,8 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) private const uint NoDataCalc = 0x809B0401u; // BadNoData | 0x401 private const uint InvalidInputsCalc = 0x80D60401u; // BadAggregateInvalidInputs | 0x401 - private static async Task ReadA(ReadTestDb db, HistoryAggregateType agg) => - await db.Source().ReadProcessedAsync("A", At(0), At(80), Twenty, agg, Ct); + private async Task ReadA(HistoryAggregateType agg) => + await B.CreateDataSource().ReadProcessedAsync("A", At(0), At(80), Twenty, agg, Ct); [Fact] public void The_status_literals_carry_the_SDK_Calculated_aggregate_bits() @@ -48,23 +46,21 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)] public async Task Numeric_aggregates_over_the_fixture(HistoryAggregateType agg, double b0, double b1, double b2) { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await ReadA(db, agg)).Samples; + await ConformanceData.SeedAAsync(B); + var s = (await ReadA(agg)).Samples; s.Count.ShouldBe(4); s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]); // B1 holds a Bad sample alongside a usable one ⇒ Uncertain; the empty B3 ⇒ BadNoData. s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]); - s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]); // bucket starts + s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(0)), B.Round(At(20)), B.Round(At(40)), B.Round(At(60))]); // bucket starts } [Fact] public async Task Count_counts_usable_samples_as_int() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await ReadA(db, HistoryAggregateType.Count)).Samples; + await ConformanceData.SeedAAsync(B); + var s = (await ReadA(HistoryAggregateType.Count)).Samples; s.Select(x => x.Value).ShouldBe([(object?)2, 1, 1, null]); s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]); } @@ -74,9 +70,8 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)] public async Task Percent_aggregates_count_every_sample(HistoryAggregateType agg, double b0, double b1, double b2) { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await ReadA(db, agg)).Samples; + await ConformanceData.SeedAAsync(B); + var s = (await ReadA(agg)).Samples; s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]); // A percentage is a statement about quality, so it is not itself degraded by the Bad input. s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, NoDataCalc]); @@ -85,34 +80,32 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task Actual_time_aggregates_stamp_the_sample_time() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var min = (await ReadA(db, HistoryAggregateType.MinimumActualTime)).Samples; + await ConformanceData.SeedAAsync(B); + var min = (await ReadA(HistoryAggregateType.MinimumActualTime)).Samples; min.Select(x => x.Value).ShouldBe([10d, 40d, 50d, null]); - min.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(30), At(40), At(60)]); + min.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(0)), B.Round(At(30)), B.Round(At(40)), B.Round(At(60))]); min.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]); - var max = (await ReadA(db, HistoryAggregateType.MaximumActualTime)).Samples; + var max = (await ReadA(HistoryAggregateType.MaximumActualTime)).Samples; max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]); - max.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]); + max.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(10)), B.Round(At(30)), B.Round(At(40)), B.Round(At(60))]); } [Fact] public async Task Actual_time_ties_resolve_to_the_earliest_sample() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("T", - (At(0), HistorianValue.FromDouble(5), ReadTestDb.Good), - (At(2), HistorianValue.FromDouble(3), ReadTestDb.Good), - (At(4), HistorianValue.FromDouble(3), ReadTestDb.Good), - (At(6), HistorianValue.FromDouble(7), ReadTestDb.Good), - (At(8), HistorianValue.FromDouble(7), ReadTestDb.Good)); - var src = db.Source(); + await B.SeedAsync("T", + (At(0), HistorianValue.FromDouble(5), Good), + (At(2), HistorianValue.FromDouble(3), Good), + (At(4), HistorianValue.FromDouble(3), Good), + (At(6), HistorianValue.FromDouble(7), Good), + (At(8), HistorianValue.FromDouble(7), Good)); + var src = B.CreateDataSource(); var min = (await src.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.MinimumActualTime, Ct)).Samples.Single(); min.Value.ShouldBe(3d); - min.SourceTimestampUtc.ShouldBe(At(2)); + min.SourceTimestampUtc.ShouldBe(B.Round(At(2))); var max = (await src.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single(); max.Value.ShouldBe(7d); - max.SourceTimestampUtc.ShouldBe(At(6)); + max.SourceTimestampUtc.ShouldBe(B.Round(At(6))); } [Fact] @@ -120,16 +113,15 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) { // T0 is not millisecond-aligned: a DATEDIFF(millisecond) bucket formula would push the sample one tick // before the +20 s edge into bucket 1 (it counts millisecond boundaries crossed, not elapsed time). - var db = await ReadTestDb.CreateAsync(fx); var edge = At(20); - await db.SeedAsync("E", - (At(0).AddTicks(-1), HistorianValue.FromDouble(1000), ReadTestDb.Good), // before start: excluded - (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good), // == start: B0 - (edge.AddTicks(-1), HistorianValue.FromDouble(2), ReadTestDb.Good), // last tick of B0 - (edge, HistorianValue.FromDouble(4), ReadTestDb.Good), // first tick of B1 - (At(40).AddTicks(-1), HistorianValue.FromDouble(8), ReadTestDb.Good), // last tick of B1 - (At(40), HistorianValue.FromDouble(2000), ReadTestDb.Good)); // == end: excluded - var s = (await db.Source().ReadProcessedAsync("E", At(0), At(40), Twenty, HistoryAggregateType.Total, Ct)).Samples; + await B.SeedAsync("E", + (At(0).AddTicks(-B.ResolutionTicks), HistorianValue.FromDouble(1000), Good), // before start: excluded + (At(0), HistorianValue.FromDouble(1), Good), // == start: B0 + (edge.AddTicks(-B.ResolutionTicks), HistorianValue.FromDouble(2), Good), // last tick of B0 + (edge, HistorianValue.FromDouble(4), Good), // first tick of B1 + (At(40).AddTicks(-B.ResolutionTicks), HistorianValue.FromDouble(8), Good), // last tick of B1 + (At(40), HistorianValue.FromDouble(2000), Good)); // == end: excluded + var s = (await B.CreateDataSource().ReadProcessedAsync("E", At(0), At(40), Twenty, HistoryAggregateType.Total, Ct)).Samples; s.Select(x => x.Value).ShouldBe([3d, 12d]); s.ShouldAllBe(x => x.StatusCode == GoodCalc); } @@ -137,23 +129,21 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task A_partial_last_interval_is_truncated_at_end() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadProcessedAsync("A", At(0), At(50), Twenty, HistoryAggregateType.Average, Ct)).Samples; - s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40)]); + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadProcessedAsync("A", At(0), At(50), Twenty, HistoryAggregateType.Average, Ct)).Samples; + s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(0)), B.Round(At(20)), B.Round(At(40))]); s.Select(x => x.Value).ShouldBe([15d, 40d, 50d]); } [Fact] public async Task A_non_positive_interval_is_one_interval_over_the_window() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var src = db.Source(); + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); var avg = (await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single(); avg.Value.ShouldBe(30d); // usable {10, 20, 40, 50} avg.StatusCode.ShouldBe(UncertainCalc); - avg.SourceTimestampUtc.ShouldBe(At(0)); + avg.SourceTimestampUtc.ShouldBe(B.Round(At(0))); (await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.FromSeconds(-1), HistoryAggregateType.Count, Ct)) .Samples.Single().Value.ShouldBe(4); } @@ -163,11 +153,10 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) { // 1601-01-01 is the OPC UA minimum DateTime. A nanosecond DATEDIFF_BIG overflows past ≈292 years (SQL 535), // and a ns-scaled interval would wrap a long; both must be gone. - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); + await ConformanceData.SeedAAsync(B); var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc); - var src = db.Source(); + var src = B.CreateDataSource(); var avg = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single(); avg.Value.ShouldBe(30d); // usable {10, 20, 40, 50} avg.StatusCode.ShouldBe(UncertainCalc); @@ -189,11 +178,10 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) // Hand-computed (calendar arithmetic, independent of SQL): 1601-01-01 → 2027-01-01 is 155 593 days = // 22 227.57 weeks ⇒ 22 228 intervals (under the 100k cap). 2026-10-09 08:00 is 155 509 days in ⇒ week // index 22 215, which starts Monday 2026-10-05 00:00; every fixture sample (t0 … t0+40 s) is in it. - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); + await ConformanceData.SeedAAsync(B); var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc); var end = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var s = (await db.Source().ReadProcessedAsync("A", start, end, TimeSpan.FromDays(7), HistoryAggregateType.Average, Ct)).Samples; + var s = (await B.CreateDataSource().ReadProcessedAsync("A", start, end, TimeSpan.FromDays(7), HistoryAggregateType.Average, Ct)).Samples; s.Count.ShouldBe(22_228); var hit = s[22_215]; @@ -207,31 +195,28 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task A_reverse_window_is_computed_forward_and_returned_descending() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var s = (await db.Source().ReadProcessedAsync("A", At(80), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples; - s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]); + await ConformanceData.SeedAAsync(B); + var s = (await B.CreateDataSource().ReadProcessedAsync("A", At(80), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples; + s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(60)), B.Round(At(40)), B.Round(At(20)), B.Round(At(0))]); s.Select(x => x.Value).ShouldBe([null, 50d, 40d, 15d]); } [Fact] public async Task Equal_start_and_end_yields_no_intervals() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - (await db.Source().ReadProcessedAsync("A", At(10), At(10), Twenty, HistoryAggregateType.Average, Ct)) + await ConformanceData.SeedAAsync(B); + (await B.CreateDataSource().ReadProcessedAsync("A", At(10), At(10), Twenty, HistoryAggregateType.Average, Ct)) .Samples.ShouldBeEmpty(); } [Fact] public async Task Outage_markers_are_bad_samples_in_aggregates() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("M", - (At(0), HistorianValue.FromDouble(10), ReadTestDb.Good), - (At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication), - (At(10), HistorianValue.FromDouble(30), ReadTestDb.Good)); - var src = db.Source(); + await B.SeedAsync("M", + (At(0), HistorianValue.FromDouble(10), Good), + (At(5), HistorianValue.Empty, BadNoCommunication), + (At(10), HistorianValue.FromDouble(30), Good)); + var src = B.CreateDataSource(); async Task One(HistoryAggregateType a) => (await src.ReadProcessedAsync("M", At(0), At(20), Twenty, a, Ct)).Samples.Single(); @@ -246,11 +231,10 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task An_interval_of_only_bad_samples_is_BadNoData_except_the_percentages() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("Z", - (At(1), HistorianValue.Empty, ReadTestDb.BadNoCommunication), - (At(2), HistorianValue.FromDouble(5), ReadTestDb.Bad)); - var src = db.Source(); + await B.SeedAsync("Z", + (At(1), HistorianValue.Empty, BadNoCommunication), + (At(2), HistorianValue.FromDouble(5), Bad)); + var src = B.CreateDataSource(); async Task One(HistoryAggregateType a) => (await src.ReadProcessedAsync("Z", At(0), At(20), Twenty, a, Ct)).Samples.Single(); @@ -264,11 +248,10 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task Uncertain_samples_are_used_but_make_the_interval_uncertain() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("U", - (At(1), HistorianValue.FromDouble(4), ReadTestDb.Uncertain), - (At(2), HistorianValue.FromDouble(6), ReadTestDb.Good)); - var s = (await db.Source().ReadProcessedAsync("U", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)).Samples.Single(); + await B.SeedAsync("U", + (At(1), HistorianValue.FromDouble(4), Uncertain), + (At(2), HistorianValue.FromDouble(6), Good)); + var s = (await B.CreateDataSource().ReadProcessedAsync("U", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)).Samples.Single(); s.Value.ShouldBe(5d); s.StatusCode.ShouldBe(UncertainCalc); } @@ -276,11 +259,10 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task String_tags_serve_count_start_end_and_refuse_numeric_aggregates() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("S", - (At(0), HistorianValue.FromString("a"), ReadTestDb.Good), - (At(10), HistorianValue.FromString("b"), ReadTestDb.Good)); - var src = db.Source(); + await B.SeedAsync("S", + (At(0), HistorianValue.FromString("a"), Good), + (At(10), HistorianValue.FromString("b"), Good)); + var src = B.CreateDataSource(); async Task One(HistoryAggregateType a) => (await src.ReadProcessedAsync("S", At(0), At(20), Twenty, a, Ct)).Samples.Single(); @@ -304,22 +286,20 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task DateTime_tags_refuse_numeric_aggregates() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("D", (At(0), HistorianValue.FromDateTime(At(-100)), ReadTestDb.Good)); - (await db.Source().ReadProcessedAsync("D", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)) + await B.SeedAsync("D", (At(0), HistorianValue.FromDateTime(At(-100)), Good)); + (await B.CreateDataSource().ReadProcessedAsync("D", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)) .Samples.Single().StatusCode.ShouldBe(InvalidInputsCalc); } [Fact] public async Task Boolean_tags_aggregate_as_zero_or_one() { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("B", - (At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good), - (At(5), HistorianValue.FromBoolean(false), ReadTestDb.Good), - (At(10), HistorianValue.FromBoolean(true), ReadTestDb.Good), - (At(15), HistorianValue.FromBoolean(true), ReadTestDb.Good)); - var src = db.Source(); + await B.SeedAsync("B", + (At(0), HistorianValue.FromBoolean(true), Good), + (At(5), HistorianValue.FromBoolean(false), Good), + (At(10), HistorianValue.FromBoolean(true), Good), + (At(15), HistorianValue.FromBoolean(true), Good)); + var src = B.CreateDataSource(); async Task One(HistoryAggregateType a) => (await src.ReadProcessedAsync("B", At(0), At(20), Twenty, a, Ct)).Samples.Single(); (await One(HistoryAggregateType.Average)).Value.ShouldBe(0.75d); @@ -331,8 +311,7 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task Unknown_tag_yields_every_interval_as_BadNoData() { - var db = await ReadTestDb.CreateAsync(fx); - var s = (await db.Source().ReadProcessedAsync("nope", At(0), At(60), Twenty, HistoryAggregateType.Average, Ct)).Samples; + var s = (await B.CreateDataSource().ReadProcessedAsync("nope", At(0), At(60), Twenty, HistoryAggregateType.Average, Ct)).Samples; s.Count.ShouldBe(3); s.ShouldAllBe(x => x.StatusCode == NoDataCalc && x.Value == null); } @@ -352,8 +331,7 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [InlineData(HistoryAggregateType.VariancePopulation)] public async Task Unsupported_aggregates_throw_not_supported_and_are_not_health_failures(HistoryAggregateType agg) { - var db = await ReadTestDb.CreateAsync(fx); - var src = db.Source(); + var src = B.CreateDataSource(); var ex = await Should.ThrowAsync( () => src.ReadProcessedAsync("A", At(0), At(20), Twenty, agg, Ct)); ex.Aggregate.ShouldBe(agg); @@ -363,13 +341,12 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) [Fact] public async Task The_bucket_count_is_capped_at_100000() { - var db = await ReadTestDb.CreateAsync(fx); - var src = db.Source(); - var oneTick = TimeSpan.FromTicks(1); - (await src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_000), oneTick, HistoryAggregateType.Count, Ct)) + var src = B.CreateDataSource(); + var oneTick = TimeSpan.FromTicks(B.ResolutionTicks); + (await src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_000 * B.ResolutionTicks), oneTick, HistoryAggregateType.Count, Ct)) .Samples.Count.ShouldBe(100_000); await Should.ThrowAsync( - () => src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_001), oneTick, HistoryAggregateType.Count, Ct)); + () => src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_001 * B.ResolutionTicks), oneTick, HistoryAggregateType.Count, Ct)); var h = src.GetHealthSnapshot(); h.TotalFailures.ShouldBe(0); // a client-request error, not a backend fault } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs b/tests/Drivers/Historian.Conformance/QualityContractConformance.cs similarity index 57% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs rename to tests/Drivers/Historian.Conformance/QualityContractConformance.cs index c08a856a..39a42d1c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs +++ b/tests/Drivers/Historian.Conformance/QualityContractConformance.cs @@ -3,51 +3,41 @@ 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; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// 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) +public abstract class QualityContractConformance : ConformanceBase { 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", [ + (await B.CreateValueWriter().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; + var raw = (await B.CreateDataSource().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; + var at = (await B.CreateDataSource().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 @@ -56,42 +46,41 @@ public sealed class QualityContractTests(SqlHistorianFixture fx) [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", [ + (await B.CreateValueWriter().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; + // Read the stored status directly so the read-side clamp cannot mask a writer that forgot to force Bad. + var stored = await B.ReadBackAsync("empty"); + stored.Count.ShouldBe(3); + stored.ShouldAllBe(r => IsBad(r.Status)); + var raw = (await B.CreateDataSource().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)); + // Seeded directly, bypassing the writer: what a pre-fix build could have stored. + await B.SeedAsync("legacy", + (At(0), HistorianValue.FromDouble(10), Good), + (At(10), HistorianValue.Empty, Good), + (At(20), HistorianValue.Empty, Uncertain), + (At(30), HistorianValue.FromDouble(40), 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 raw = (await B.CreateDataSource().ReadRawAsync("legacy", At(0), At(100), 0, Ct)).Samples; + raw.Select(s => s.StatusCode).ShouldBe([Good, BadNoData, BadNoData, Good]); - var at = (await db.Source().ReadAtTimeAsync("legacy", [At(10), At(15), At(25)], Ct)).Samples; + var at = (await B.CreateDataSource().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, [ + var r = await B.CreateUpdater().UpdateDataAsync("u", HistoryUpdateMode.Insert, [ new(At(0), HistorianValue.Empty, 0u), new(At(1), HistorianValue.Empty, 0x40000000u), new(At(2), HistorianValue.Empty, BadNoCommunication), @@ -99,7 +88,7 @@ public sealed class QualityContractTests(SqlHistorianFixture fx) ], Ct); r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]); - (await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples + (await B.CreateDataSource().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples .Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]); } } diff --git a/tests/Drivers/Historian.Conformance/RawReadConformance.cs b/tests/Drivers/Historian.Conformance/RawReadConformance.cs new file mode 100644 index 00000000..07380824 --- /dev/null +++ b/tests/Drivers/Historian.Conformance/RawReadConformance.cs @@ -0,0 +1,134 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; + +/// Raw-read behaviour over the fixture tag A (see ). +public abstract class RawReadConformance : ConformanceBase +{ + private static readonly CancellationToken Ct = CancellationToken.None; + + private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray(); + + [Fact] + public async Task Forward_read_is_start_inclusive_end_exclusive_ascending_with_exact_ticks() + { + await ConformanceData.SeedAAsync(B); + var r = await B.CreateDataSource().ReadRawAsync("A", At(10), At(40), 0, Ct); + + Values(r).ShouldBe([20d, 30d, 40d]); // +10 included, +40 (== end) excluded + r.Samples.Select(s => s.SourceTimestampUtc!.Value.Ticks).ShouldBe([B.Round(At(10)).Ticks, B.Round(At(20)).Ticks, B.Round(At(30)).Ticks]); + r.Samples.Select(s => s.StatusCode).ShouldBe([Good, Bad, Good]); + r.Samples[0].SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); + r.Samples[0].ServerTimestampUtc.Ticks.ShouldBe(B.Round(At(11)).Ticks); // stored ServerTimeUtc, not "now" + r.ContinuationPoint.ShouldBeNull(); + } + + [Fact] + public async Task Forward_read_honours_the_limit() + { + await ConformanceData.SeedAAsync(B); + Values(await B.CreateDataSource().ReadRawAsync("A", At(0), At(100), 2, Ct)).ShouldBe([10d, 20d]); + } + + [Fact] + public async Task Zero_max_values_is_unbounded() + { + await ConformanceData.SeedAAsync(B); + Values(await B.CreateDataSource().ReadRawAsync("A", At(-1), At(100), 0, Ct)).ShouldBe([10d, 20d, 30d, 40d, 50d]); + Values(await B.CreateDataSource().ReadRawAsync("A", At(-1), At(100), uint.MaxValue, Ct)).Length.ShouldBe(5); + } + + [Fact] + public async Task Reverse_read_is_descending_over_end_exclusive_start_inclusive() + { + await ConformanceData.SeedAAsync(B); + // start=+30, end=+10 ⇒ (+10, +30] descending. + Values(await B.CreateDataSource().ReadRawAsync("A", At(30), At(10), 0, Ct)).ShouldBe([40d, 30d]); + Values(await B.CreateDataSource().ReadRawAsync("A", At(100), At(-1), 2, Ct)).ShouldBe([50d, 40d]); + } + + [Fact] + public async Task Equal_start_and_end_is_a_point_read() + { + await ConformanceData.SeedAAsync(B); + Values(await B.CreateDataSource().ReadRawAsync("A", At(10), At(10), 0, Ct)).ShouldBe([20d]); + // One resolution unit later: the stored sample is no longer at that instant. + var later = At(10).AddTicks(B.ResolutionTicks); + (await B.CreateDataSource().ReadRawAsync("A", later, later, 0, Ct)).Samples.ShouldBeEmpty(); + } + + [Fact] + public async Task A_resumed_page_from_the_last_timestamp_inclusive_continues_without_gaps() + { + // The node manager's paging: read cap N, resume at the last returned timestamp INCLUSIVE, trim it. + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); + var p1 = await src.ReadRawAsync("A", At(0), At(100), 2, Ct); + var p2 = await src.ReadRawAsync("A", p1.Samples[^1].SourceTimestampUtc!.Value, At(100), 2, Ct); + Values(p1).ShouldBe([10d, 20d]); + Values(p2).ShouldBe([20d, 30d]); // first is the boundary sample the node manager trims + } + + [Fact] + public async Task Unknown_tag_is_an_empty_result_and_reads_never_create_tags() + { + var src = B.CreateDataSource(); + (await src.ReadRawAsync("nope", At(0), At(10), 0, Ct)).Samples.ShouldBeEmpty(); + await src.ReadAtTimeAsync("nope", [At(1)], Ct); + await src.ReadProcessedAsync("nope", At(0), At(10), TimeSpan.FromSeconds(5), HistoryAggregateType.Average, Ct); + (await B.TagExistsAsync("nope")).ShouldBeFalse(); + src.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } + + [Fact] + public async Task Outage_markers_and_bad_samples_come_back_with_their_bad_status() + { + await B.SeedAsync("M", + (At(0), HistorianValue.FromDouble(10), Good), + (At(5), HistorianValue.Empty, BadNoCommunication), + (At(7), HistorianValue.FromDouble(99), Bad)); + var r = await B.CreateDataSource().ReadRawAsync("M", At(0), At(10), 0, Ct); + r.Samples.Count.ShouldBe(3); + r.Samples[1].Value.ShouldBeNull(); + r.Samples[1].StatusCode.ShouldBe(0x80310000u); + r.Samples[2].Value.ShouldBe(99d); + r.Samples[2].StatusCode.ShouldBe(0x80000000u); + } + + [Fact] + public async Task Every_kind_round_trips_to_its_CLR_type() + { + var dt = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(7 * B.ResolutionTicks); + await B.SeedAsync("K", + (At(0), HistorianValue.FromInt64(-9_000_000_000L), Good), + (At(1), HistorianValue.FromBoolean(true), Good), + (At(2), HistorianValue.FromString("héllo"), Good), + (At(3), HistorianValue.FromDateTime(dt), Good), + (At(4), HistorianValue.FromDouble(1.5), Uncertain)); + var s = (await B.CreateDataSource().ReadRawAsync("K", At(0), At(5), 0, Ct)).Samples; + s[0].Value.ShouldBe(-9_000_000_000L); + s[1].Value.ShouldBe(true); + s[2].Value.ShouldBe("héllo"); + s[3].Value.ShouldBe(dt); + ((DateTime)s[3].Value!).Kind.ShouldBe(DateTimeKind.Utc); + s[4].Value.ShouldBe(1.5); + s[4].StatusCode.ShouldBe(0x40000000u); + } + + [Fact] + public async Task Successful_reads_are_counted_in_health() + { + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); + await src.ReadRawAsync("A", At(0), At(10), 0, Ct); + await src.ReadRawAsync("A", At(0), At(10), 0, Ct); + var h = src.GetHealthSnapshot(); + h.TotalQueries.ShouldBe(2); + h.TotalSuccesses.ShouldBe(2); + h.ConsecutiveFailures.ShouldBe(0); + h.LastSuccessTime.ShouldNotBeNull(); + h.ProcessConnectionOpen.ShouldBeTrue(); + } +} diff --git a/tests/Drivers/Historian.Conformance/TagNameConformance.cs b/tests/Drivers/Historian.Conformance/TagNameConformance.cs new file mode 100644 index 00000000..46054377 --- /dev/null +++ b/tests/Drivers/Historian.Conformance/TagNameConformance.cs @@ -0,0 +1,64 @@ +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.Conformance; + +/// +/// Names longer than the 400-character tag-name limit are refused, never truncated. Each test seeds a series under +/// the 400-character PREFIX of the long name: a sized parameter would silently cut the long name to that prefix +/// and hit the wrong series. +/// +public abstract class TagNameConformance : ConformanceBase +{ + private static readonly CancellationToken Ct = CancellationToken.None; + + private const uint BadInvalidArgument = 0x80AB0000u; + private const uint BadNoData = 0x809B0000u; + + private static readonly string Prefix = new('p', 400); + private static readonly string Long = Prefix + "-x"; + + [Fact] + public async Task The_writer_refuses_a_long_name_as_a_per_tag_failure_and_writes_nothing() + { + await B.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), Good)); + var w = B.CreateValueWriter(); + + (await w.WriteLiveValuesAsync(Long, [new(At(5), HistorianValue.FromDouble(2), 0u)], Ct)).ShouldBeFalse(); + + (await B.ReadBackAsync(Prefix)).Count.ShouldBe(1); // nothing landed on the prefix series + (await B.TagExistsAsync(Long)).ShouldBeFalse(); // and the long tag was not created + } + + [Fact] + public async Task The_updater_answers_BadInvalidArgument_for_every_item_of_a_long_name() + { + await B.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), Good)); + var u = B.CreateUpdater(); + + (await u.UpdateDataAsync(Long, HistoryUpdateMode.Update, + [new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], Ct)) + .ShouldBe([BadInvalidArgument, BadInvalidArgument]); + (await u.DeleteRawAsync(Long, At(-1), At(10), Ct)).ShouldBe(BadInvalidArgument); + (await u.DeleteAtTimeAsync(Long, [At(0)], Ct)).ShouldBe([BadInvalidArgument]); + + // The prefix series is untouched: not replaced, not deleted. + var raw = (await B.CreateDataSource().ReadRawAsync(Prefix, At(-1), At(10), 0, Ct)).Samples; + raw.Select(s => s.Value).ShouldBe([(object)1d]); + } + + [Fact] + public async Task Reads_of_a_long_name_are_not_found_rather_than_the_prefix_series() + { + await B.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), Good)); + var src = B.CreateDataSource(); + + (await src.ReadRawAsync(Long, At(-1), At(10), 0, Ct)).Samples.ShouldBeEmpty(); + (await src.ReadAtTimeAsync(Long, [At(0)], Ct)).Samples.Single().StatusCode.ShouldBe(BadNoData); + (await src.ReadProcessedAsync(Long, At(-1), At(10), TimeSpan.Zero, HistoryAggregateType.Count, Ct)) + .Samples.Single().StatusCode.ShouldBe(HistorianInfoBits.WithCalculated(BadNoData)); + src.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs b/tests/Drivers/Historian.Conformance/UpdateConformance.cs similarity index 62% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs rename to tests/Drivers/Historian.Conformance/UpdateConformance.cs index 98001164..4d60029e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs +++ b/tests/Drivers/Historian.Conformance/UpdateConformance.cs @@ -1,205 +1,173 @@ -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; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; -[Collection(SqlHistorianCollection.Name)] -public sealed class UpdateTests(SqlHistorianFixture fx) +/// History update/delete result codes and their effect on storage. +public abstract class UpdateConformance : ConformanceBase { - private const uint Good = HistorianStatusCodes.Good; private const uint UncertainStatus = HistorianStatusCodes.UncertainDataSubNormal; - // Deliberately NOT millisecond aligned: sub-ms ticks must survive the TVP, MERGE join and DELETE join. + // Deliberately NOT millisecond (or microsecond) aligned: sub-resolution ticks must survive; stored times are B.Round(T(n)). private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static DateTime T(int n) => T0.AddTicks(1234567L * n + 3); - private (SqlServerHistorianDataUpdater U, string Schema) Make() - { - fx.SkipIfUnavailable(); - var schema = SqlHistorianFixture.UniqueSchema(); - var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = schema }; - var conn = new SqlHistorianConnection(o); - var boot = new SqlServerSchemaBootstrapper(conn, o); - return (new SqlServerHistorianDataUpdater(conn, new SqlServerTagCatalog(conn, boot)), schema); - } - private static HistorizationValue V(DateTime? t, double d, uint status = Good) => new(t, HistorianValue.FromDouble(d), status); - private async Task> RowsAsync(string schema, string tag) - { - await using var c = new SqlConnection(fx.ConnectionString); - await c.OpenAsync(); - await using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT s.SourceTimeUtc, s.NumValue, s.StatusCode FROM [{schema}].Sample s JOIN [{schema}].Tag g ON g.TagId=s.TagId WHERE g.Name=@n ORDER BY s.SourceTimeUtc"; - cmd.Parameters.AddWithValue("@n", tag); - var list = new List<(DateTime, double?, int)>(); - await using var rd = await cmd.ExecuteReaderAsync(); - while (await rd.ReadAsync()) - list.Add((DateTime.SpecifyKind(rd.GetDateTime(0), DateTimeKind.Utc), rd.IsDBNull(1) ? null : rd.GetDouble(1), rd.GetInt32(2))); - return list; - } - - private async Task TagCountAsync(string schema) - { - await using var c = new SqlConnection(fx.ConnectionString); - await c.OpenAsync(); - await using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT COUNT(*) FROM [{schema}].Tag"; - return Convert.ToInt64(await cmd.ExecuteScalarAsync()); - } - private static readonly CancellationToken Ct = CancellationToken.None; [Fact] public async Task Insert_new_is_GoodEntryInserted_and_existing_is_BadEntryExists_in_order() { - var (u, s) = Make(); + var u = B.CreateUpdater(); var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct); r1.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]); // Mixed: T(2) exists, T(3) new, T(1) exists - out of timestamp order on purpose. var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], Ct); r2.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.BadEntryExists]); - var rows = await RowsAsync(s, "t"); - rows.Select(x => x.Num).ShouldBe([1d, 2d, 3d]); // existing values untouched - rows.Select(x => x.Time).ShouldBe([T(1), T(2), T(3)]); // sub-ms ticks preserved + var rows = await B.ReadBackAsync("t"); + rows.Select(x => (double?)x.V.Number).ShouldBe([1d, 2d, 3d]); // existing values untouched + rows.Select(x => x.T).ShouldBe([B.Round(T(1)), B.Round(T(2)), B.Round(T(3))]); // sub-ms ticks preserved } [Fact] public async Task Replace_existing_is_GoodEntryReplaced_with_new_value_and_status_missing_is_BadNoEntryExists() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], Ct); r.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.GoodEntryReplaced]); - var rows = await RowsAsync(s, "t"); + var rows = await B.ReadBackAsync("t"); rows.Count.ShouldBe(2); // T(5) was not created - rows[0].Num.ShouldBe(11d); - rows[0].Status.ShouldBe((int)Good); - rows[1].Num.ShouldBe(22d); - rows[1].Status.ShouldBe(unchecked((int)UncertainStatus)); // replaced value visible with its new status + rows[0].V.Number.ShouldBe(11d); + rows[0].Status.ShouldBe(Good); + rows[1].V.Number.ShouldBe(22d); + rows[1].Status.ShouldBe(UncertainStatus); // replaced value visible with its new status } [Fact] public async Task Update_upserts_inserted_or_replaced_per_item() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], Ct); r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]); - (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([10d, 2d]); + (await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([10d, 2d]); } [Fact] public async Task Unknown_tag_Insert_and_Update_create_it_but_Replace_and_deletes_do_not() { - var (u, s) = Make(); + var u = B.CreateUpdater(); (await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], Ct)) .ShouldBe([HistorianStatusCodes.BadNoEntryExists]); (await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); (await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]); - (await TagCountAsync(s)).ShouldBe(0); // none of those created a tag + foreach (var name in new[] { "rep", "del", "del2" }) + (await B.TagExistsAsync(name)).ShouldBeFalse(name); // none of those created a tag (await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]); (await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]); - (await TagCountAsync(s)).ShouldBe(2); + (await B.TagExistsAsync("ins")).ShouldBeTrue(); + (await B.TagExistsAsync("upd")).ShouldBeTrue(); } [Fact] public async Task Null_and_MinValue_timestamps_are_BadInvalidTimestamp_and_others_proceed() { - var (u, s) = Make(); + var u = B.CreateUpdater(); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], Ct); r.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadInvalidTimestamp]); - (await RowsAsync(s, "t")).Count.ShouldBe(1); + (await B.ReadBackAsync("t")).Count.ShouldBe(1); // All-invalid request: no DB work, no tag created, still one code per item. - var (u2, s2) = Make(); + var u2 = B.CreateUpdater(); await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); // bootstraps the schema (await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]); - (await TagCountAsync(s2)).ShouldBe(1); // only "seed"; "x" was never created + (await B.TagExistsAsync("seed")).ShouldBeTrue(); + (await B.TagExistsAsync("x")).ShouldBeFalse(); // "x" was never created } [Fact] public async Task NaN_and_Infinity_are_rejected_with_BadInvalidArgument_and_others_proceed() { - var (u, s) = Make(); + var u = B.CreateUpdater(); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], Ct); r.ShouldBe([HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument]); - (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([2d]); + (await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([2d]); } [Fact] public async Task Duplicate_timestamp_in_one_Insert_request_second_is_BadEntryExists_first_wins() { - var (u, s) = Make(); + var u = B.CreateUpdater(); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], Ct); r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]); - (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([1d, 2d]); + (await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([1d, 2d]); } [Fact] public async Task Duplicate_timestamp_in_one_Replace_or_Update_request_last_wins_and_all_share_the_code() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], Ct); rep.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryReplaced]); - (await RowsAsync(s, "t")).Single().Num.ShouldBe(6d); + (await B.ReadBackAsync("t")).Single().V.Number.ShouldBe(6d); var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], Ct); upd.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]); - (await RowsAsync(s, "t")).Last().Num.ShouldBe(8d); + (await B.ReadBackAsync("t")).Last().V.Number.ShouldBe(8d); } [Fact] public async Task Replace_and_Update_can_change_the_value_kind_and_clear_stale_columns() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], Ct); (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]); - var rows = await RowsAsync(s, "t"); - rows.Single().Num.ShouldBeNull(); // NumValue cleared by the String replace + var rows = await B.ReadBackAsync("t"); + rows.Single().V.ShouldBe(HistorianValue.FromString("hi")); // Number cleared (0), not left at 1.5 } [Fact] public async Task DeleteRaw_is_half_open_Good_when_rows_removed_BadNoData_when_none() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct); // [T(1), T(3)) removes T(1) and T(2); T(3) (the end) survives. (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(Good); - (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(3)]); + (await B.ReadBackAsync("t")).Select(x => x.T).ShouldBe([B.Round(T(3))]); (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); - // sub-ms boundary: a start one tick after T(3) excludes it. - (await u.DeleteRawAsync("t", T(3).AddTicks(1), T(3).AddTicks(100), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); - (await RowsAsync(s, "t")).Count.ShouldBe(1); + // sub-ms boundary: a start one resolution unit after T(3) excludes it. + (await u.DeleteRawAsync("t", T(3).AddTicks(B.ResolutionTicks), T(3).AddTicks(100 * B.ResolutionTicks), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); + (await B.ReadBackAsync("t")).Count.ShouldBe(1); } [Fact] public async Task DeleteAtTime_reports_per_time_in_order_with_exact_tick_matching() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct); - var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(1) /* near miss */, T(1), DateTime.MinValue], Ct); + var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(B.ResolutionTicks) /* near miss */, T(1), DateTime.MinValue], Ct); r.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, Good, HistorianStatusCodes.BadInvalidTimestamp]); - (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(2)]); + (await B.ReadBackAsync("t")).Select(x => x.T).ShouldBe([B.Round(T(2))]); } [Fact] public async Task DeleteAtTime_duplicate_time_first_reports_Good_later_BadNoEntryExists() { - var (u, s) = Make(); + var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); (await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]); - (await RowsAsync(s, "t")).ShouldBeEmpty(); + (await B.ReadBackAsync("t")).ShouldBeEmpty(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs index 1b34e04d..665f9dfa 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs @@ -16,57 +16,9 @@ public sealed class LongNameTests(SqlHistorianFixture fx) private static readonly CancellationToken Ct = CancellationToken.None; private static DateTime At(double s) => ReadTestDb.At(s); - private const uint BadInvalidArgument = 0x80AB0000u; - private const uint BadNoData = 0x809B0000u; - private static readonly string Prefix = new('p', 400); private static readonly string Long = Prefix + "-x"; - [Fact] - public async Task The_writer_refuses_a_long_name_as_a_per_tag_failure_and_writes_nothing() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good)); - var w = new SqlServerHistorianValueWriter(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot)); - - (await w.WriteLiveValuesAsync(Long, [new(At(5), HistorianValue.FromDouble(2), 0u)], Ct)).ShouldBeFalse(); - - (await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Sample")}")).ShouldBe(1); - (await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Tag")}")).ShouldBe(1); - } - - [Fact] - public async Task The_updater_answers_BadInvalidArgument_for_every_item_of_a_long_name() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good)); - var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot)); - - (await u.UpdateDataAsync(Long, HistoryUpdateMode.Update, - [new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], Ct)) - .ShouldBe([BadInvalidArgument, BadInvalidArgument]); - (await u.DeleteRawAsync(Long, At(-1), At(10), Ct)).ShouldBe(BadInvalidArgument); - (await u.DeleteAtTimeAsync(Long, [At(0)], Ct)).ShouldBe([BadInvalidArgument]); - - // The prefix series is untouched: not replaced, not deleted. - var raw = (await db.Source().ReadRawAsync(Prefix, At(-1), At(10), 0, Ct)).Samples; - raw.Select(s => s.Value).ShouldBe([(object)1d]); - } - - [Fact] - public async Task Reads_of_a_long_name_are_not_found_rather_than_the_prefix_series() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good)); - var src = db.Source(); - - (await src.ReadRawAsync(Long, At(-1), At(10), 0, Ct)).Samples.ShouldBeEmpty(); - (await src.ReadAtTimeAsync(Long, [At(0)], Ct)).Samples.Single().StatusCode.ShouldBe(BadNoData); - (await src.ReadProcessedAsync(Long, At(-1), At(10), TimeSpan.Zero, HistoryAggregateType.Count, Ct)) - .Samples.Single().StatusCode.ShouldBe(HistorianInfoBits.WithCalculated(BadNoData)); - src.GetHealthSnapshot().TotalFailures.ShouldBe(0); - } - [Fact] public async Task An_event_read_filtered_by_a_long_source_matches_nothing() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs index 8d62d081..6a7a213e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs @@ -6,230 +6,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; -/// -/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it. -/// Shared by the four read test classes. is deliberately NOT millisecond-aligned. -/// -internal sealed class ReadTestDb -{ - /// Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose). - public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567); - - public const uint Good = 0x00000000u; - public const uint Bad = 0x80000000u; - public const uint BadNoCommunication = 0x80310000u; - public const uint Uncertain = 0x40000000u; - - 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) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond)); - - public static async Task 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); - } - - /// A fresh data source (fresh catalog cache) over this schema; is its "now". - public SqlServerHistorianDataSource Source(TimeProvider? clock = null) => - new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock); - - /// Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart). - public async Task 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; - } - - /// The fixture tag A of the brief: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double). - public Task SeedAAsync(string name = "A") => SeedAsync(name, - (At(0), HistorianValue.FromDouble(10), Good), - (At(10), HistorianValue.FromDouble(20), Good), - (At(20), HistorianValue.FromDouble(30), Bad), - (At(30), HistorianValue.FromDouble(40), Good), - (At(40), HistorianValue.FromDouble(50), Good)); - - public async Task 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()); - } -} - [Collection(SqlHistorianCollection.Name)] public sealed class RawReadTests(SqlHistorianFixture fx) { private static readonly CancellationToken Ct = CancellationToken.None; private static DateTime At(double s) => ReadTestDb.At(s); - private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray(); - - [Fact] - public async Task Forward_read_is_start_inclusive_end_exclusive_ascending_with_exact_ticks() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var r = await db.Source().ReadRawAsync("A", At(10), At(40), 0, Ct); - - Values(r).ShouldBe([20d, 30d, 40d]); // +10 included, +40 (== end) excluded - r.Samples.Select(s => s.SourceTimestampUtc!.Value.Ticks).ShouldBe([At(10).Ticks, At(20).Ticks, At(30).Ticks]); - r.Samples.Select(s => s.StatusCode).ShouldBe([ReadTestDb.Good, ReadTestDb.Bad, ReadTestDb.Good]); - r.Samples[0].SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); - r.Samples[0].ServerTimestampUtc.Ticks.ShouldBe(At(11).Ticks); // stored ServerTimeUtc, not "now" - r.ContinuationPoint.ShouldBeNull(); - } - - [Fact] - public async Task Forward_read_honours_the_limit() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - Values(await db.Source().ReadRawAsync("A", At(0), At(100), 2, Ct)).ShouldBe([10d, 20d]); - } - - [Fact] - public async Task Zero_max_values_is_unbounded() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - Values(await db.Source().ReadRawAsync("A", At(-1), At(100), 0, Ct)).ShouldBe([10d, 20d, 30d, 40d, 50d]); - Values(await db.Source().ReadRawAsync("A", At(-1), At(100), uint.MaxValue, Ct)).Length.ShouldBe(5); - } - - [Fact] - public async Task Reverse_read_is_descending_over_end_exclusive_start_inclusive() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - // start=+30, end=+10 ⇒ (+10, +30] descending. - Values(await db.Source().ReadRawAsync("A", At(30), At(10), 0, Ct)).ShouldBe([40d, 30d]); - Values(await db.Source().ReadRawAsync("A", At(100), At(-1), 2, Ct)).ShouldBe([50d, 40d]); - } - - [Fact] - public async Task Equal_start_and_end_is_a_point_read() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - Values(await db.Source().ReadRawAsync("A", At(10), At(10), 0, Ct)).ShouldBe([20d]); - (await db.Source().ReadRawAsync("A", At(10).AddTicks(1), At(10).AddTicks(1), 0, Ct)).Samples.ShouldBeEmpty(); - } - - [Fact] - public async Task A_resumed_page_from_the_last_timestamp_inclusive_continues_without_gaps() - { - // The node manager's paging: read cap N, resume at the last returned timestamp INCLUSIVE, trim it. - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var src = db.Source(); - var p1 = await src.ReadRawAsync("A", At(0), At(100), 2, Ct); - var p2 = await src.ReadRawAsync("A", p1.Samples[^1].SourceTimestampUtc!.Value, At(100), 2, Ct); - Values(p1).ShouldBe([10d, 20d]); - Values(p2).ShouldBe([20d, 30d]); // first is the boundary sample the node manager trims - } - - [Fact] - public async Task Unknown_tag_is_an_empty_result_and_reads_never_create_tags() - { - var db = await ReadTestDb.CreateAsync(fx); - var src = db.Source(); - (await src.ReadRawAsync("nope", At(0), At(10), 0, Ct)).Samples.ShouldBeEmpty(); - await src.ReadAtTimeAsync("nope", [At(1)], Ct); - await src.ReadProcessedAsync("nope", At(0), At(10), TimeSpan.FromSeconds(5), HistoryAggregateType.Average, Ct); - (await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Tag")}")).ShouldBe(0); - src.GetHealthSnapshot().TotalFailures.ShouldBe(0); - } - - [Fact] - public async Task Outage_markers_and_bad_samples_come_back_with_their_bad_status() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAsync("M", - (At(0), HistorianValue.FromDouble(10), ReadTestDb.Good), - (At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication), - (At(7), HistorianValue.FromDouble(99), ReadTestDb.Bad)); - var r = await db.Source().ReadRawAsync("M", At(0), At(10), 0, Ct); - r.Samples.Count.ShouldBe(3); - r.Samples[1].Value.ShouldBeNull(); - r.Samples[1].StatusCode.ShouldBe(0x80310000u); - r.Samples[2].Value.ShouldBe(99d); - r.Samples[2].StatusCode.ShouldBe(0x80000000u); - } - - [Fact] - public async Task Every_kind_round_trips_to_its_CLR_type() - { - var db = await ReadTestDb.CreateAsync(fx); - var dt = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(7); - await db.SeedAsync("K", - (At(0), HistorianValue.FromInt64(-9_000_000_000L), ReadTestDb.Good), - (At(1), HistorianValue.FromBoolean(true), ReadTestDb.Good), - (At(2), HistorianValue.FromString("héllo"), ReadTestDb.Good), - (At(3), HistorianValue.FromDateTime(dt), ReadTestDb.Good), - (At(4), HistorianValue.FromDouble(1.5), ReadTestDb.Uncertain)); - var s = (await db.Source().ReadRawAsync("K", At(0), At(5), 0, Ct)).Samples; - s[0].Value.ShouldBe(-9_000_000_000L); - s[1].Value.ShouldBe(true); - s[2].Value.ShouldBe("héllo"); - s[3].Value.ShouldBe(dt); - ((DateTime)s[3].Value!).Kind.ShouldBe(DateTimeKind.Utc); - s[4].Value.ShouldBe(1.5); - s[4].StatusCode.ShouldBe(0x40000000u); - } - - [Fact] - public async Task Successful_reads_are_counted_in_health() - { - var db = await ReadTestDb.CreateAsync(fx); - await db.SeedAAsync(); - var src = db.Source(); - await src.ReadRawAsync("A", At(0), At(10), 0, Ct); - await src.ReadRawAsync("A", At(0), At(10), 0, Ct); - var h = src.GetHealthSnapshot(); - h.TotalQueries.ShouldBe(2); - h.TotalSuccesses.ShouldBe(2); - h.ConsecutiveFailures.ShouldBe(0); - h.LastSuccessTime.ShouldNotBeNull(); - h.ProcessConnectionOpen.ShouldBeTrue(); - } - [Fact] public async Task A_missing_schema_surfaces_in_health_LastError_and_is_not_latched() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ReadTestDb.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ReadTestDb.cs new file mode 100644 index 00000000..8af5e084 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ReadTestDb.cs @@ -0,0 +1,87 @@ +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; + +/// +/// 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 . is deliberately NOT millisecond-aligned. +/// +internal sealed class ReadTestDb +{ + /// Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose). + 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 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); + } + + /// A fresh data source (fresh catalog cache) over this schema; is its "now". + public SqlServerHistorianDataSource Source(TimeProvider? clock = null) => + new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock); + + /// Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart). + public async Task 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 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()); + } +} + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs new file mode 100644 index 00000000..5a02a4c5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs @@ -0,0 +1,55 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; + +// One concrete subclass per conformance class: the suite's tests run here against SQL Server. + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerRawReadConformance(SqlHistorianFixture fx) : RawReadConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerAtTimeConformance(SqlHistorianFixture fx) : AtTimeConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerProcessedConformance(SqlHistorianFixture fx) : ProcessedConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerQualityContractConformance(SqlHistorianFixture fx) : QualityContractConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerLiveWriteConformance(SqlHistorianFixture fx) : LiveWriteConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerUpdateConformance(SqlHistorianFixture fx) : UpdateConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerTagNameConformance(SqlHistorianFixture fx) : TagNameConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformanceBackend.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformanceBackend.cs new file mode 100644 index 00000000..e4c27a8c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformanceBackend.cs @@ -0,0 +1,77 @@ +using Microsoft.Data.SqlClient; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; + +/// +/// The SQL Server historian as a conformance backend: a fresh unique schema per instance (inside the fixture's +/// throwaway database), seeded by plain INSERTs through . +/// +internal sealed class SqlServerConformanceBackend : IConformanceBackend +{ + private readonly ReadTestDb _db; + + private SqlServerConformanceBackend(ReadTestDb db) => _db = db; + + /// Creates a backend over a fresh schema; skips the calling test when the gate is closed. + public static async Task CreateAsync(SqlHistorianFixture fx) => + new SqlServerConformanceBackend(await ReadTestDb.CreateAsync(fx)); + + /// + public long ResolutionTicks => 1; + + /// + public IHistorianDataSource CreateDataSource(TimeProvider? clock = null) => _db.Source(clock); + + /// + public IHistorianValueWriter CreateValueWriter(Func? utcNow = null) => + new SqlServerHistorianValueWriter(_db.Conn, _db.Boot, new SqlServerTagCatalog(_db.Conn, _db.Boot), utcNow: utcNow); + + /// + public IHistorianDataUpdater CreateUpdater() => + new SqlServerHistorianDataUpdater(_db.Conn, new SqlServerTagCatalog(_db.Conn, _db.Boot)); + + /// + public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples) => + await _db.SeedAsync(tag, samples); + + /// + public async Task> ReadBackAsync(string tag) + { + await using var c = await _db.Conn.OpenAsync(CancellationToken.None); + await using var cmd = _db.Conn.CreateCommand(c, + $"SELECT s.SourceTimeUtc, s.ValueKind, s.NumValue, s.IntValue, s.StrValue, s.StatusCode FROM {_db.Conn.Q("Sample")} s " + + $"JOIN {_db.Conn.Q("Tag")} t ON t.TagId = s.TagId WHERE t.Name = @n ORDER BY s.SourceTimeUtc"); + cmd.Parameters.Add(new SqlParameter("@n", System.Data.SqlDbType.NVarChar, -1) { Value = tag }); + var list = new List<(DateTime, HistorianValue, uint)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + var v = SampleColumnMapper.FromColumns(r.GetByte(1), r.IsDBNull(2) ? null : r.GetDouble(2), + r.IsDBNull(3) ? null : r.GetInt64(3), r.IsDBNull(4) ? null : r.GetString(4)); + list.Add((SqlHistorianConnection.GetUtc(r, 0), v, SampleColumnMapper.FromDbStatus(r.GetInt32(5)))); + } + return list; + } + + /// + public async Task TagExistsAsync(string tag) => await ScalarTagAsync("COUNT(*)", tag) is > 0; + + /// + public async Task TagDataTypeAsync(string tag) => + await ScalarTagAsync("MAX(CONVERT(bigint, DataType))", tag) is { } v ? (DriverDataType)(byte)v : null; // MAX over no row is NULL + + private async Task ScalarTagAsync(string expr, string tag) + { + await using var c = await _db.Conn.OpenAsync(CancellationToken.None); + await using var cmd = _db.Conn.CreateCommand(c, $"SELECT {expr} FROM {_db.Conn.Q("Tag")} WHERE Name = @n"); + cmd.Parameters.Add(new SqlParameter("@n", System.Data.SqlDbType.NVarChar, -1) { Value = tag }); + var o = await cmd.ExecuteScalarAsync(); + return o is null or DBNull ? null : Convert.ToInt64(o); + } + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; // the fixture drops the whole database +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs index 3945257e..60582f6f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs @@ -37,101 +37,6 @@ public sealed class WriteTests(SqlHistorianFixture fx) return l; } - [Fact] - public async Task Every_kind_round_trips_with_exact_ticks_and_status() - { - fx.SkipIfUnavailable(); - var (w, s) = Make(); - var dt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(77); - HistorizationValue[] vals = - [ - new(T0, HistorianValue.FromDouble(1.25), 0), - new(T0.AddTicks(1), HistorianValue.FromInt64(long.MaxValue), 0x40000000u), - new(T0.AddTicks(2), HistorianValue.FromBoolean(true), 0), - new(T0.AddTicks(3), HistorianValue.FromString("héllo"), 0), - new(T0.AddTicks(4), HistorianValue.FromDateTime(dt), 0), - new(T0.AddTicks(5), HistorianValue.Empty, 0x80310000u), - ]; - (await w.WriteLiveValuesAsync("tag.a", vals, CancellationToken.None)).ShouldBeTrue(); - var rows = await RowsAsync(s, "tag.a"); - rows.Count.ShouldBe(6); - for (var i = 0; i < 6; i++) rows[i].T.Ticks.ShouldBe(vals[i].TimestampUtc!.Value.Ticks); - var back = rows.Select(r => SampleColumnMapper.FromColumns(r.Kind, r.N, r.I, r.S)).ToList(); - back.ShouldBe(vals.Select(v => v.Value).ToList()); - (rows[5].N, rows[5].I, rows[5].S).ShouldBe((null, null, null)); - rows[1].Status.ShouldBe(unchecked((int)0x40000000u)); - SampleColumnMapper.FromDbStatus(rows[5].Status).ShouldBe(0x80310000u); - } - - [Fact] - public async Task Writing_the_same_batch_twice_is_idempotent() - { - fx.SkipIfUnavailable(); - var (w, s) = Make(); - HistorizationValue[] vals = [new(T0, HistorianValue.FromDouble(1), 0), new(T0.AddSeconds(1), HistorianValue.FromDouble(2), 0)]; - (await w.WriteLiveValuesAsync("tag.b", vals, CancellationToken.None)).ShouldBeTrue(); - (await w.WriteLiveValuesAsync("tag.b", vals, CancellationToken.None)).ShouldBeTrue(); - // Overlapping batch: only the new row lands, the stored first value is untouched. - (await w.WriteLiveValuesAsync("tag.b", [new(T0, HistorianValue.FromDouble(99), 0), new(T0.AddSeconds(2), HistorianValue.FromDouble(3), 0)], CancellationToken.None)).ShouldBeTrue(); - var rows = await RowsAsync(s, "tag.b"); - rows.Count.ShouldBe(3); - rows[0].N.ShouldBe(1); - } - - [Fact] - public async Task Within_batch_duplicate_timestamps_do_not_fail_and_first_wins() - { - fx.SkipIfUnavailable(); - var (w, s) = Make(); - (await w.WriteLiveValuesAsync("tag.c", [new(T0, HistorianValue.FromDouble(1), 0), new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); - var rows = await RowsAsync(s, "tag.c"); - rows.Count.ShouldBe(1); - rows[0].N.ShouldBe(1); - } - - [Fact] - public async Task Non_finite_doubles_store_as_Empty_forced_Bad_and_do_not_fail_the_write() - { - fx.SkipIfUnavailable(); - var (w, s) = Make(); - (await w.WriteLiveValuesAsync("tag.d", [ - new(T0, HistorianValue.FromDouble(double.NaN), 0x40000000u), - new(T0.AddTicks(1), HistorianValue.FromDouble(double.PositiveInfinity), 0), - new(T0.AddTicks(2), HistorianValue.FromDouble(7), 0)], CancellationToken.None)).ShouldBeTrue(); - var rows = await RowsAsync(s, "tag.d"); - rows.Count.ShouldBe(3); - rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty); - rows[0].N.ShouldBeNull(); - rows[0].Status.ShouldBe(unchecked((int)HistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1) - rows[1].Status.ShouldBe(unchecked((int)HistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1) - rows[2].N.ShouldBe(7); - } - - [Fact] - public async Task Empty_batch_succeeds_without_a_call_and_null_timestamp_is_stamped() - { - fx.SkipIfUnavailable(); - var (w, s) = Make(); - (await w.WriteLiveValuesAsync("tag.e", [], CancellationToken.None)).ShouldBeTrue(); - (await w.WriteLiveValuesAsync("tag.e", [new(null, HistorianValue.FromInt64(5), 0)], CancellationToken.None)).ShouldBeTrue(); - var rows = await RowsAsync(s, "tag.e"); - rows.Count.ShouldBe(1); - (DateTime.UtcNow - rows[0].T).ShouldBeLessThan(TimeSpan.FromMinutes(1)); - } - - [Fact] - public async Task Tag_is_created_with_the_first_typed_values_data_type() - { - fx.SkipIfUnavailable(); - var (w, s) = Make(); - await w.WriteLiveValuesAsync("tag.f", [new(T0, HistorianValue.Empty, 0x80310000u), new(T0.AddTicks(1), HistorianValue.FromString("x"), 0)], CancellationToken.None); - await using var c = new SqlConnection(fx.ConnectionString); - await c.OpenAsync(); - await using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT DataType FROM [{s}].Tag WHERE Name='tag.f'"; - Convert.ToByte(await cmd.ExecuteScalarAsync()).ShouldBe((byte)DriverDataType.String); - } - [Fact] public async Task Ten_thousand_values_write_in_under_five_seconds() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests.csproj index 74dbd900..4da917dd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests.csproj @@ -21,6 +21,12 @@ + + + + + +