using Shouldly; using Xunit; 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.QuestDb.IntegrationTests; /// /// QuestDB AtTime regressions found in the T5 review (M1, M3), and the data source's reported timestamp /// resolution (the HistoryRead paging step, C1). /// [Collection(QuestDbCollection.Name)] public sealed class AtTimeRegressionTests(QuestDbFixture fx) : IAsyncLifetime { private static readonly CancellationToken Ct = CancellationToken.None; private const uint GoodInterpolated = 0x00000402u; private QuestDbConformanceBackend _b = null!; private static DateTime At(double s) => ConformanceData.At(s); public async ValueTask InitializeAsync() => _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx); public async ValueTask DisposeAsync() { if (_b is not null) await _b.DisposeAsync(); } /// /// M3: a request time with a sub-microsecond remainder is answered stamped with THAT time (as SQL Server does), /// not with the rounded time the seek used. The value is still the one at the rounded time. /// [Fact] public async Task AtTime_result_is_stamped_with_the_original_sub_microsecond_request_time() { await ConformanceData.SeedAAsync(_b); // +0=10, +10=20, ... var requested = _b.Round(At(5)).AddTicks(3); // 0.3 µs past a microsecond (T0 itself is not µs-aligned) var unspecified = DateTime.SpecifyKind(_b.Round(At(7)).AddTicks(7), DateTimeKind.Unspecified); var s = (await _b.CreateDataSource().ReadAtTimeAsync("A", [requested, unspecified], Ct)).Samples; s[0].SourceTimestampUtc.ShouldBe(requested); s[0].SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); s[0].Value.ShouldBe(15d); // evaluated at the rounded time Round(At(5)) s[0].StatusCode.ShouldBe(GoodInterpolated); s[1].SourceTimestampUtc.ShouldBe(DateTime.SpecifyKind(unspecified, DateTimeKind.Utc)); s[1].SourceTimestampUtc!.Value.Ticks.ShouldBe(unspecified.Ticks); } /// M3 on the unknown-tag path: every BadNoData entry carries its own requested time. [Fact] public async Task AtTime_unknown_tag_entries_are_stamped_with_the_original_request_time() { var requested = _b.Round(At(5)).AddTicks(3); var s = (await _b.CreateDataSource().ReadAtTimeAsync("nope", [requested], Ct)).Samples; s.Single().StatusCode.ShouldBe(HistorianStatusCodes.BadNoData); s.Single().SourceTimestampUtc.ShouldBe(requested); } /// /// M1: the NEXT seek skips a tombstone and finds the next live row. With +10 deleted, At(5) lies between the /// live +0 (10) and the live +20 (30), so it interpolates to 15, Good. Seeing the tombstone as "next" would /// instead hold 10 as Uncertain (an Empty BadNoData neighbour is not usable). /// [Fact] public async Task AtTime_next_seek_skips_a_tombstone_and_finds_the_next_live_row() { await _b.SeedAsync("M", (At(0), HistorianValue.FromDouble(10), ConformanceData.Good), (At(10), HistorianValue.FromDouble(20), ConformanceData.Good), (At(20), HistorianValue.FromDouble(30), ConformanceData.Good)); await _b.InsertRowsAsync("M", [(At(10), HistorianValue.Empty, HistorianStatusCodes.BadNoData, true)]); var s = (await _b.CreateDataSource().ReadAtTimeAsync("M", [At(5)], Ct)).Samples.Single(); s.Value.ShouldBe(15d); // 10 + (30 - 10) * 5/20 s.StatusCode.ShouldBe(GoodInterpolated); } /// C1: the data source reports QuestDB's microsecond storage resolution to the paging code. [Fact] public void Data_source_reports_a_ten_tick_timestamp_resolution() => ((IHistorianDataSource)_b.CreateDataSource()).TimestampResolutionTicks.ShouldBe(10); }