diff --git a/tests/Drivers/Historian.Conformance/ProcessedConformance.cs b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs index cb220b5b9..a7cc74e4d 100644 --- a/tests/Drivers/Historian.Conformance/ProcessedConformance.cs +++ b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs @@ -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( () => 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( + () => under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.DurationGood, Ct))).Limit.ShouldBe(4); + (await Should.ThrowAsync( + () => 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))