test(historian): backend conformance suite (shared source), run against SQL Server
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+50
-64
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
@@ -0,0 +1,40 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
/// <summary>Shared fixture data of the conformance suite. <see cref="T0"/> is deliberately NOT millisecond- or microsecond-aligned.</summary>
|
||||
public static class ConformanceData
|
||||
{
|
||||
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
|
||||
public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567);
|
||||
|
||||
/// <summary>Good.</summary>
|
||||
public const uint Good = 0x00000000u;
|
||||
/// <summary>Bad (generic).</summary>
|
||||
public const uint Bad = 0x80000000u;
|
||||
/// <summary>BadNoCommunication.</summary>
|
||||
public const uint BadNoCommunication = 0x80310000u;
|
||||
/// <summary>Uncertain (generic).</summary>
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>T0 + <paramref name="seconds"/> seconds.</summary>
|
||||
public static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond));
|
||||
|
||||
/// <summary>The fixture tag A: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double).</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>A settable clock for a data source's "now".</summary>
|
||||
public sealed class ConformanceClock(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
/// <summary>The current instant.</summary>
|
||||
public DateTimeOffset Now { get; set; } = now;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IConformanceBackend : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Storage timestamp resolution in .NET ticks: 1 for SQL Server (100 ns), 10 for QuestDB (µs).</summary>
|
||||
long ResolutionTicks { get; }
|
||||
|
||||
/// <summary>Truncates a timestamp to the backend's storage resolution (UTC kind). Every timestamp assertion goes through it.</summary>
|
||||
/// <param name="t">The timestamp.</param>
|
||||
/// <returns>The value the backend stores/serves for <paramref name="t"/>.</returns>
|
||||
DateTime Round(DateTime t) => new(t.Ticks - t.Ticks % ResolutionTicks, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this backend's store.</summary>
|
||||
/// <param name="clock">Its "now"; null means the system clock.</param>
|
||||
IHistorianDataSource CreateDataSource(TimeProvider? clock = null);
|
||||
|
||||
/// <summary>A fresh live-value writer over this backend's store.</summary>
|
||||
/// <param name="utcNow">The clock used to stamp values that carry no timestamp; null means the system clock.</param>
|
||||
IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null);
|
||||
|
||||
/// <summary>A fresh history updater over this backend's store.</summary>
|
||||
IHistorianDataUpdater CreateUpdater();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag name.</param>
|
||||
/// <param name="samples">The rows to store.</param>
|
||||
Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag name.</param>
|
||||
Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> ReadBackAsync(string tag);
|
||||
|
||||
/// <summary>Whether a tag with exactly this name exists in storage (no truncation of long names).</summary>
|
||||
/// <param name="tag">The tag name.</param>
|
||||
Task<bool> TagExistsAsync(string tag);
|
||||
|
||||
/// <summary>The data type the backend recorded for a tag, or null when the tag does not exist.</summary>
|
||||
/// <param name="tag">The tag name.</param>
|
||||
Task<DriverDataType?> TagDataTypeAsync(string tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base of every conformance class. A backend's test project subclasses each abstract suite class once,
|
||||
/// supplying <see cref="CreateBackendAsync"/>, which must call its fixture's skip gate so the tests report Skipped
|
||||
/// (not Failed) when the backend is unavailable.
|
||||
/// </summary>
|
||||
public abstract class ConformanceBase : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Good.</summary>
|
||||
protected const uint Good = ConformanceData.Good;
|
||||
/// <summary>Bad (generic).</summary>
|
||||
protected const uint Bad = ConformanceData.Bad;
|
||||
/// <summary>BadNoCommunication.</summary>
|
||||
protected const uint BadNoCommunication = ConformanceData.BadNoCommunication;
|
||||
/// <summary>Uncertain (generic).</summary>
|
||||
protected const uint Uncertain = ConformanceData.Uncertain;
|
||||
|
||||
/// <summary>The backend under test for the current test.</summary>
|
||||
protected IConformanceBackend B { get; private set; } = null!;
|
||||
|
||||
/// <summary>Creates this test's isolated backend; calls the fixture's skip gate first.</summary>
|
||||
protected abstract Task<IConformanceBackend> CreateBackendAsync();
|
||||
|
||||
/// <summary>T0 + <paramref name="s"/> seconds.</summary>
|
||||
protected static DateTime At(double s) => ConformanceData.At(s);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask InitializeAsync() => B = await CreateBackendAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (B is not null) await B.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Live value writer behaviour: round-trips, idempotent redelivery, first-wins duplicates, non-finite doubles.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+80
-103
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Push-down aggregates over tag A (+0=10, +10=20, +20=Bad(30), +30=40, +40=50), 20 s intervals from the
|
||||
/// sub-millisecond <see cref="ReadTestDb.T0"/>, window [t0, t0+80s) ⇒ four buckets:
|
||||
/// sub-millisecond <see cref="ConformanceData.T0"/>, 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.
|
||||
/// </summary>
|
||||
[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<HistoryReadResult> ReadA(ReadTestDb db, HistoryAggregateType agg) =>
|
||||
await db.Source().ReadProcessedAsync("A", At(0), At(80), Twenty, agg, Ct);
|
||||
private async Task<HistoryReadResult> 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<DataValueSnapshot> 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<DataValueSnapshot> 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<DataValueSnapshot> 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<DataValueSnapshot> 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<HistoryAggregateNotSupportedException>(
|
||||
() => 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<ArgumentOutOfRangeException>(
|
||||
() => 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
|
||||
}
|
||||
+22
-33
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Final-review I-1: a stored sample is never served with a better quality than "no value". An Empty (null-value)
|
||||
/// sample is always Bad — forced Bad by the value writer, refused non-Bad by the updater, and clamped to
|
||||
/// <c>BadNoData</c> on read for rows written before either rule existed.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class QualityContractTests(SqlHistorianFixture fx)
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
/// <summary>Raw-read behaviour over the fixture tag A (see <see cref="ConformanceData.SeedAAsync"/>).</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+46
-78
@@ -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)
|
||||
/// <summary>History update/delete result codes and their effect on storage.</summary>
|
||||
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<List<(DateTime Time, double? Num, int Status)>> 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<long> 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();
|
||||
}
|
||||
}
|
||||
-48
@@ -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()
|
||||
{
|
||||
|
||||
-218
@@ -6,230 +6,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it.
|
||||
/// Shared by the four read test classes. <see cref="T0"/> is deliberately NOT millisecond-aligned.
|
||||
/// </summary>
|
||||
internal sealed class ReadTestDb
|
||||
{
|
||||
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
|
||||
public static readonly DateTime T0 = 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<ReadTestDb> CreateAsync(SqlHistorianFixture fx)
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema() };
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
await boot.EnsureAsync(CancellationToken.None);
|
||||
return new ReadTestDb(o, conn, boot);
|
||||
}
|
||||
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
|
||||
public SqlServerHistorianDataSource Source(TimeProvider? clock = null) =>
|
||||
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock);
|
||||
|
||||
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
|
||||
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
|
||||
{
|
||||
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
||||
int id;
|
||||
await using (var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT {Conn.Q("Tag")} (Name, DataType, CreatedUtc, UpdatedUtc) OUTPUT inserted.TagId VALUES (@n, 0, SYSUTCDATETIME(), SYSUTCDATETIME())"))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@n", tag);
|
||||
id = (int)(await cmd.ExecuteScalarAsync())!;
|
||||
}
|
||||
foreach (var (t, v, status) in samples)
|
||||
{
|
||||
var col = SampleColumnMapper.ToColumns(v);
|
||||
await using var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT {Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
|
||||
"VALUES (@id, @t, @k, @num, @int, @str, @st, @srv)");
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
||||
cmd.Parameters.AddWithValue("@k", col.ValueKind);
|
||||
cmd.Parameters.Add(new SqlParameter("@num", System.Data.SqlDbType.Float) { Value = (object?)col.NumValue ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@int", System.Data.SqlDbType.BigInt) { Value = (object?)col.IntValue ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@str", System.Data.SqlDbType.NVarChar, -1) { Value = (object?)col.StrValue ?? DBNull.Value });
|
||||
cmd.Parameters.AddWithValue("@st", SampleColumnMapper.ToDbStatus(status));
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>The fixture tag A of the brief: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double).</summary>
|
||||
public Task<int> 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<long> ScalarAsync(string sql)
|
||||
{
|
||||
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
||||
await using var cmd = Conn.CreateCommand(c, sql);
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
||||
+87
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it.
|
||||
/// Shared by the SQL Server-specific tests and by <see cref="SqlServerConformanceBackend"/>. <see cref="T0"/> is deliberately NOT millisecond-aligned.
|
||||
/// </summary>
|
||||
internal sealed class ReadTestDb
|
||||
{
|
||||
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
|
||||
public static readonly DateTime T0 = Conformance.ConformanceData.T0;
|
||||
|
||||
public const uint Good = Conformance.ConformanceData.Good;
|
||||
public const uint Bad = Conformance.ConformanceData.Bad;
|
||||
public const uint BadNoCommunication = Conformance.ConformanceData.BadNoCommunication;
|
||||
public const uint Uncertain = Conformance.ConformanceData.Uncertain;
|
||||
|
||||
private ReadTestDb(SqlServerHistorianOptions options, SqlHistorianConnection conn, SqlServerSchemaBootstrapper boot)
|
||||
{
|
||||
Options = options;
|
||||
Conn = conn;
|
||||
Boot = boot;
|
||||
}
|
||||
|
||||
public SqlServerHistorianOptions Options { get; }
|
||||
public SqlHistorianConnection Conn { get; }
|
||||
public SqlServerSchemaBootstrapper Boot { get; }
|
||||
|
||||
public static DateTime At(double seconds) => Conformance.ConformanceData.At(seconds);
|
||||
|
||||
public static async Task<ReadTestDb> CreateAsync(SqlHistorianFixture fx)
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema() };
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
await boot.EnsureAsync(CancellationToken.None);
|
||||
return new ReadTestDb(o, conn, boot);
|
||||
}
|
||||
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
|
||||
public SqlServerHistorianDataSource Source(TimeProvider? clock = null) =>
|
||||
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock);
|
||||
|
||||
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
|
||||
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
|
||||
{
|
||||
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
||||
int id;
|
||||
await using (var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT {Conn.Q("Tag")} (Name, DataType, CreatedUtc, UpdatedUtc) OUTPUT inserted.TagId VALUES (@n, 0, SYSUTCDATETIME(), SYSUTCDATETIME())"))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@n", tag);
|
||||
id = (int)(await cmd.ExecuteScalarAsync())!;
|
||||
}
|
||||
foreach (var (t, v, status) in samples)
|
||||
{
|
||||
var col = SampleColumnMapper.ToColumns(v);
|
||||
await using var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT {Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
|
||||
"VALUES (@id, @t, @k, @num, @int, @str, @st, @srv)");
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
||||
cmd.Parameters.AddWithValue("@k", col.ValueKind);
|
||||
cmd.Parameters.Add(new SqlParameter("@num", System.Data.SqlDbType.Float) { Value = (object?)col.NumValue ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@int", System.Data.SqlDbType.BigInt) { Value = (object?)col.IntValue ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@str", System.Data.SqlDbType.NVarChar, -1) { Value = (object?)col.StrValue ?? DBNull.Value });
|
||||
cmd.Parameters.AddWithValue("@st", SampleColumnMapper.ToDbStatus(status));
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task<long> ScalarAsync(string sql)
|
||||
{
|
||||
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
||||
await using var cmd = Conn.CreateCommand(c, sql);
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerAtTimeConformance(SqlHistorianFixture fx) : AtTimeConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerProcessedConformance(SqlHistorianFixture fx) : ProcessedConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerQualityContractConformance(SqlHistorianFixture fx) : QualityContractConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerLiveWriteConformance(SqlHistorianFixture fx) : LiveWriteConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerUpdateConformance(SqlHistorianFixture fx) : UpdateConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerTagNameConformance(SqlHistorianFixture fx) : TagNameConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
+77
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ReadTestDb"/>.
|
||||
/// </summary>
|
||||
internal sealed class SqlServerConformanceBackend : IConformanceBackend
|
||||
{
|
||||
private readonly ReadTestDb _db;
|
||||
|
||||
private SqlServerConformanceBackend(ReadTestDb db) => _db = db;
|
||||
|
||||
/// <summary>Creates a backend over a fresh schema; skips the calling test when the gate is closed.</summary>
|
||||
public static async Task<IConformanceBackend> CreateAsync(SqlHistorianFixture fx) =>
|
||||
new SqlServerConformanceBackend(await ReadTestDb.CreateAsync(fx));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long ResolutionTicks => 1;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHistorianDataSource CreateDataSource(TimeProvider? clock = null) => _db.Source(clock);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null) =>
|
||||
new SqlServerHistorianValueWriter(_db.Conn, _db.Boot, new SqlServerTagCatalog(_db.Conn, _db.Boot), utcNow: utcNow);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHistorianDataUpdater CreateUpdater() =>
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples) =>
|
||||
await _db.SeedAsync(tag, samples);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> TagExistsAsync(string tag) => await ScalarTagAsync("COUNT(*)", tag) is > 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DriverDataType?> 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<long?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask; // the fixture drops the whole database
|
||||
}
|
||||
-95
@@ -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()
|
||||
{
|
||||
|
||||
+6
@@ -21,6 +21,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The shared, backend-neutral conformance suite (also linked into the QuestDB test project). -->
|
||||
<Compile Include="..\Historian.Conformance\**\*.cs" LinkBase="Conformance"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user