Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs
T
Joseph Doherty 821763a09d fix(historian): SQL quality contract, long-name refusal, info-bit masking, AtTime-after-now, bootstrap hardening
- I-1: an Empty sample is always Bad. The writer stores a non-Bad NaN/Inf as
  Empty+BadOutOfRange and any other non-Bad Empty as BadNoData; the updater
  refuses a non-Bad Empty with BadInvalidArgument; reads clamp a legacy
  non-Bad Empty row to BadNoData (Raw and AtTime).
- Tag/source names over 400 chars are refused, never truncated: writer
  per-tag failure, updater BadInvalidArgument, reads not-found, events none.
- HistorianInfoBits masks the historian bits before setting one.
- AtTime past now, stepped from the last sample => UncertainDataSubNormal.
- Bootstrapper: logger, 5 s negative cache, ClearPool + Warning when
  sp_releaseapplock fails.
- Provider builds its shared state lazily (an invalid Schema no longer
  throws at Resolve); Kinds is a FrozenSet; Gateway reuses the recorder's
  DefaultSupportedKinds.
- Cleanup: SqlHistorianDataTypes in its own file (updater dup removed),
  dead Invalidate and unused status consts deleted, named severity masks,
  alarm writer brace.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 14:16:31 -04:00

203 lines
9.0 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <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)
{
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;
private const uint UncertainInterpolated = 0x40A40402u; // UncertainDataSubNormal | 0x402
private const uint BadNoData = 0x809B0000u;
[Fact]
public async Task Interpolated_bits_are_the_SDK_AggregateBits_Interpolated()
{
var code = new Opc.Ua.StatusCode(GoodInterpolated);
code.AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
Opc.Ua.StatusCode.IsGood(code).ShouldBeTrue();
new Opc.Ua.StatusCode(UncertainInterpolated).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
await Task.CompletedTask;
}
[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;
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[1].Value.ShouldBe(30d); // a stored Bad sample comes back as stored
s[1].StatusCode.ShouldBe(0x80000000u);
}
[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;
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
}
[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();
s.Value.ShouldBeNull();
s.StatusCode.ShouldBe(BadNoData);
s.SourceTimestampUtc.ShouldBe(At(0).AddTicks(-1));
}
[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();
s.Value.ShouldBe(20d); // NOT 25 (interpolating toward the Bad 30)
s.StatusCode.ShouldBe(UncertainInterpolated);
}
[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();
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));
}
[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;
s[0].Value.ShouldBe(10d); // bridging 10→30 would give 15
s[0].StatusCode.ShouldBe(UncertainInterpolated);
s[1].Value.ShouldBeNull(); // bridging would give 25
s[1].StatusCode.ShouldBe(0x80310000u); // inside the outage: BadNoCommunication, as the marker says
s[2].Value.ShouldBeNull(); // exact hit on the marker: as stored
s[2].StatusCode.ShouldBe(0x80310000u);
}
[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();
s.Value.ShouldBe(50d);
s.StatusCode.ShouldBe(GoodInterpolated);
}
[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))));
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
s[1].Value.ShouldBe(50d);
s[1].StatusCode.ShouldBe(UncertainInterpolated); // after now: the value is not known to still hold
}
[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();
s.Value.ShouldBe(15d);
s.StatusCode.ShouldBe(UncertainInterpolated);
}
[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;
s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero)
s[1].Value.ShouldBe(11L); // 11.0
s[0].StatusCode.ShouldBe(GoodInterpolated);
}
[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 b = (await src.ReadAtTimeAsync("B", [At(9)], Ct)).Samples.Single();
b.Value.ShouldBe(true);
b.StatusCode.ShouldBe(GoodInterpolated);
var s = (await src.ReadAtTimeAsync("S", [At(5)], Ct)).Samples.Single();
s.Value.ShouldBe("alpha");
s.StatusCode.ShouldBe(0x40000402u); // the previous sample's code (Uncertain) + Interpolated
(await src.ReadAtTimeAsync("D", [At(5)], Ct)).Samples.Single().Value.ShouldBe(d0);
}
[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;
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)]);
}
[Fact]
public async Task Empty_request_and_unknown_tag()
{
var db = await ReadTestDb.CreateAsync(fx);
var src = db.Source();
(await src.ReadAtTimeAsync("A", [], Ct)).Samples.ShouldBeEmpty();
var s = (await src.ReadAtTimeAsync("nope", [At(1), At(2)], Ct)).Samples;
s.Count.ShouldBe(2);
s.ShouldAllBe(x => x.StatusCode == BadNoData && x.Value == null);
}
}