test(historian): shared ProcessedConformance asserts DurationGood/DurationBad on every SQL backend (#840)

#820 served DurationGood/DurationBad (ms) on SqlServer/QuestDB but only
QualityMixConformance asserted them. The shared Processed suite now covers
them like the percentages: hand values over tag A (20000/10000/20000/BadNoData
and 0/10000/0) with statuses + timestamps and Duration = Percent x interval;
the SDK-calculator parity theory (forward/reverse/truncated); outage markers;
the only-Bad interval; string tags; and the time-weighted row cap.
Live: SqlServer.IntegrationTests 211/211 (central SQL), QuestDb.IntegrationTests
249 passed / 1 skipped (shared /opt/otopcua-questdb fixture).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-11 03:50:12 -05:00
parent 1696e34868
commit 7095e128e3
@@ -127,6 +127,27 @@ public abstract class ProcessedConformance : ConformanceBase
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc | 0x4u, NoDataCalc]);
}
[Theory]
[InlineData(HistoryAggregateType.DurationGood, 20_000d, 10_000d, 20_000d)]
[InlineData(HistoryAggregateType.DurationBad, 0d, 10_000d, 0d)]
public async Task Duration_aggregates_are_the_region_times_in_milliseconds(HistoryAggregateType agg, double b0, double b1,
double b2)
{
// #820/#840 (Part 13 §5.4.3.31/32): the same stepped regions as the percentages above, as an OPC UA Duration (ms) —
// B1: [20,30) Bad = 10 000 ms, [30,40) Good = 10 000 ms; B2 runs from the last sample (+40) to the interval end,
// Partial; B3 is after the end of the data ⇒ BadNoData. Same statuses as PercentGood/PercentBad.
await ConformanceData.SeedAAsync(B);
var s = (await ReadA(agg)).Samples;
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc | Partial, NoDataCalc]);
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(0)), B.Round(At(20)), B.Round(At(40)), B.Round(At(60))]);
// Duration = Percent × interval ÷ 100, interval by interval (one oracle for both families).
var percent = (await ReadA(agg == HistoryAggregateType.DurationGood
? HistoryAggregateType.PercentGood : HistoryAggregateType.PercentBad)).Samples;
for (var i = 0; i < 3; i++)
((double)s[i].Value!).ShouldBe((double)percent[i].Value! * Twenty.TotalMilliseconds / 100, 1e-9, $"bucket {i}");
}
[Fact]
public async Task Actual_time_aggregates_stamp_the_sample_time()
{
@@ -415,6 +436,7 @@ public abstract class ProcessedConformance : ConformanceBase
HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad, // #820/#840
})
{
var ours = (await src.ReadProcessedAsync("P", At(from), At(to), interval, agg, Ct)).Samples.ToList();
@@ -481,6 +503,9 @@ public abstract class ProcessedConformance : ConformanceBase
(await One(HistoryAggregateType.Start)).Value.ShouldBe(10d);
// #680: duration-based — [0,5) Good, [5,10) Bad, [10,20) Good (no end bound: the last region runs to the interval end).
((double)(await One(HistoryAggregateType.PercentBad)).Value!).ShouldBe(25d, 1e-9);
// #840: the same regions in ms — [5,10) Bad = 5 000, [0,5) + [10,20) Good = 15 000.
((double)(await One(HistoryAggregateType.DurationBad)).Value!).ShouldBe(5_000d, 1e-9);
((double)(await One(HistoryAggregateType.DurationGood)).Value!).ShouldBe(15_000d, 1e-9);
}
[Fact]
@@ -507,6 +532,11 @@ public abstract class ProcessedConformance : ConformanceBase
pb.Value.ShouldBe(100d);
// The tag's data starts (+1) inside the interval ⇒ Partial on the non-Bad percentage (#677).
pb.StatusCode.ShouldBe(GoodCalc | 0x4u);
// #840: DurationBad is the same statement in ms — the whole 20 s interval — with the same status.
var db = await One(HistoryAggregateType.DurationBad);
db.Value.ShouldBe(20_000d);
db.StatusCode.ShouldBe(GoodCalc | 0x4u);
(await One(HistoryAggregateType.DurationGood)).Value.ShouldBe(0d);
}
[Fact]
@@ -662,6 +692,7 @@ public abstract class ProcessedConformance : ConformanceBase
(await One(HistoryAggregateType.Start)).Value.ShouldBe("a");
(await One(HistoryAggregateType.End)).Value.ShouldBe("b");
(await One(HistoryAggregateType.PercentGood)).Value.ShouldBe(100d);
(await One(HistoryAggregateType.DurationGood)).Value.ShouldBe(20_000d); // #840: quality, not value — strings too
foreach (var a in new[]
{
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
@@ -845,6 +876,11 @@ public abstract class ProcessedConformance : ConformanceBase
// #680: PercentGood/PercentBad are duration-based, served by the same window stream ⇒ capped too.
(await Should.ThrowAsync<HistoryReadLimitExceededException>(
() => under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.PercentGood, Ct))).Limit.ShouldBe(4);
// #820/#840: DurationGood/DurationBad ride the same window stream ⇒ capped too.
(await Should.ThrowAsync<HistoryReadLimitExceededException>(
() => under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.DurationGood, Ct))).Limit.ShouldBe(4);
(await Should.ThrowAsync<HistoryReadLimitExceededException>(
() => under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.DurationBad, Ct))).Limit.ShouldBe(4);
// Pushed-down (per-sample) aggregates are not capped; the bound rows outside the window do not count.
(await under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.Average, Ct)).Samples.Count.ShouldBe(4);
(await B.CreateDataSource(3).ReadProcessedAsync("W", At(0), At(80), Twenty, HistoryAggregateType.TimeAverage, Ct))