using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// Push-down aggregates over tag A (+0=10, +10=20, +20=Bad(30), +30=40, +40=50), 20 s intervals from the /// sub-millisecond , 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. /// public abstract class ProcessedConformance : ConformanceBase { private static readonly CancellationToken Ct = CancellationToken.None; private static readonly TimeSpan Twenty = TimeSpan.FromSeconds(20); // Calculated = InfoType DataValue 0x400 | Calculated 0x1, OR-ed onto the code. private const uint GoodCalc = 0x00000401u; private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal | 0x401 private const uint NoDataCalc = 0x809B0401u; // BadNoData | 0x401 private const uint InvalidInputsCalc = 0x80D60401u; // BadAggregateInvalidInputs | 0x401 private async Task 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() { new Opc.Ua.StatusCode(GoodCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated); new Opc.Ua.StatusCode(UncertainCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated); new Opc.Ua.StatusCode(UncertainCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal); new Opc.Ua.StatusCode(NoDataCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadNoData); new Opc.Ua.StatusCode(NoDataCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated); new Opc.Ua.StatusCode(InvalidInputsCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadAggregateInvalidInputs); } [Theory] [InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)] [InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)] [InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)] [InlineData(HistoryAggregateType.Total, 30d, 40d, 50d)] [InlineData(HistoryAggregateType.Start, 10d, 40d, 50d)] [InlineData(HistoryAggregateType.End, 20d, 40d, 50d)] [InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)] [InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)] public async Task Numeric_aggregates_over_the_fixture(HistoryAggregateType agg, double b0, double b1, double b2) { 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([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() { 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]); } [Theory] [InlineData(HistoryAggregateType.PercentGood, 100d, 50d, 100d)] [InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)] public async Task Percent_aggregates_count_every_sample(HistoryAggregateType agg, double b0, double b1, double b2) { 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]); } [Fact] public async Task Actual_time_aggregates_stamp_the_sample_time() { 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([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(HistoryAggregateType.MaximumActualTime)).Samples; max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]); 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() { 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(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(B.Round(At(6))); } [Fact] public async Task Bucket_edges_are_start_inclusive_end_exclusive_to_the_tick() { // 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 edge = At(20); 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); } [Fact] public async Task A_partial_last_interval_is_truncated_at_end() { 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() { 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(B.Round(At(0))); (await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.FromSeconds(-1), HistoryAggregateType.Count, Ct)) .Samples.Single().Value.ShouldBe(4); } [Fact] public async Task A_whole_range_single_interval_from_1601_to_9999_does_not_overflow() { // 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. 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 = 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); avg.SourceTimestampUtc.ShouldBe(start); (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Count, Ct)) .Samples.Single().Value.ShouldBe(4); // Also a whole-DateTime-range window (0001 → 9999-12-31T23:59:59.9999999). (await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Total, Ct)) .Samples.Single().Value.ShouldBe(120d); // An interval wider than the window (here TimeSpan.MaxValue) is the same single interval, no overflow. (await src.ReadProcessedAsync("A", start, end, TimeSpan.MaxValue, HistoryAggregateType.Total, Ct)) .Samples.Single().Value.ShouldBe(120d); src.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task A_weekly_interval_over_four_centuries_lands_samples_in_the_right_week() { // 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. 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 B.CreateDataSource().ReadProcessedAsync("A", start, end, TimeSpan.FromDays(7), HistoryAggregateType.Average, Ct)).Samples; s.Count.ShouldBe(22_228); var hit = s[22_215]; hit.SourceTimestampUtc.ShouldBe(new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc)); hit.Value.ShouldBe(30d); hit.StatusCode.ShouldBe(UncertainCalc); s.Where((_, i) => i != 22_215).ShouldAllBe(x => x.StatusCode == NoDataCalc); s[^1].SourceTimestampUtc.ShouldBe(new DateTime(2026, 12, 28, 0, 0, 0, DateTimeKind.Utc)); // partial last week } [Fact] public async Task A_reverse_window_is_computed_forward_and_returned_descending() { 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() { 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() { 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 One(HistoryAggregateType a) => (await src.ReadProcessedAsync("M", At(0), At(20), Twenty, a, Ct)).Samples.Single(); var avg = await One(HistoryAggregateType.Average); avg.Value.ShouldBe(20d); avg.StatusCode.ShouldBe(UncertainCalc); (await One(HistoryAggregateType.Count)).Value.ShouldBe(2); (await One(HistoryAggregateType.Start)).Value.ShouldBe(10d); ((double)(await One(HistoryAggregateType.PercentBad)).Value!).ShouldBe(33.333333333333336, 1e-9); // 100 × 1/3 } [Fact] public async Task An_interval_of_only_bad_samples_is_BadNoData_except_the_percentages() { await B.SeedAsync("Z", (At(1), HistorianValue.Empty, BadNoCommunication), (At(2), HistorianValue.FromDouble(5), Bad)); var src = B.CreateDataSource(); async Task One(HistoryAggregateType a) => (await src.ReadProcessedAsync("Z", At(0), At(20), Twenty, a, Ct)).Samples.Single(); (await One(HistoryAggregateType.Average)).StatusCode.ShouldBe(NoDataCalc); (await One(HistoryAggregateType.Count)).StatusCode.ShouldBe(NoDataCalc); var pb = await One(HistoryAggregateType.PercentBad); pb.Value.ShouldBe(100d); pb.StatusCode.ShouldBe(GoodCalc); } [Fact] public async Task Uncertain_samples_are_used_but_make_the_interval_uncertain() { 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); } [Fact] public async Task String_tags_serve_count_start_end_and_refuse_numeric_aggregates() { await B.SeedAsync("S", (At(0), HistorianValue.FromString("a"), Good), (At(10), HistorianValue.FromString("b"), Good)); var src = B.CreateDataSource(); async Task One(HistoryAggregateType a) => (await src.ReadProcessedAsync("S", At(0), At(20), Twenty, a, Ct)).Samples.Single(); (await One(HistoryAggregateType.Count)).Value.ShouldBe(2); (await One(HistoryAggregateType.Start)).Value.ShouldBe("a"); (await One(HistoryAggregateType.End)).Value.ShouldBe("b"); (await One(HistoryAggregateType.PercentGood)).Value.ShouldBe(100d); foreach (var a in new[] { HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum, HistoryAggregateType.Total, HistoryAggregateType.Delta, HistoryAggregateType.Range, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime, }) { var r = await One(a); r.StatusCode.ShouldBe(InvalidInputsCalc, a.ToString()); r.Value.ShouldBeNull(); } } [Fact] public async Task DateTime_tags_refuse_numeric_aggregates() { 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() { 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 One(HistoryAggregateType a) => (await src.ReadProcessedAsync("B", At(0), At(20), Twenty, a, Ct)).Samples.Single(); (await One(HistoryAggregateType.Average)).Value.ShouldBe(0.75d); (await One(HistoryAggregateType.Total)).Value.ShouldBe(3d); (await One(HistoryAggregateType.Start)).Value.ShouldBe(true); (await One(HistoryAggregateType.Count)).Value.ShouldBe(4); } [Fact] public async Task Unknown_tag_yields_every_interval_as_BadNoData() { 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); } [Theory] [InlineData(HistoryAggregateType.Interpolative)] [InlineData(HistoryAggregateType.TimeAverage)] [InlineData(HistoryAggregateType.StartBound)] [InlineData(HistoryAggregateType.EndBound)] [InlineData(HistoryAggregateType.DeltaBounds)] [InlineData(HistoryAggregateType.DurationGood)] [InlineData(HistoryAggregateType.DurationBad)] [InlineData(HistoryAggregateType.WorstQuality)] [InlineData(HistoryAggregateType.NumberOfTransitions)] [InlineData(HistoryAggregateType.AnnotationCount)] [InlineData(HistoryAggregateType.StandardDeviationPopulation)] [InlineData(HistoryAggregateType.VariancePopulation)] public async Task Unsupported_aggregates_throw_not_supported_and_are_not_health_failures(HistoryAggregateType agg) { var src = B.CreateDataSource(); var ex = await Should.ThrowAsync( () => src.ReadProcessedAsync("A", At(0), At(20), Twenty, agg, Ct)); ex.Aggregate.ShouldBe(agg); src.GetHealthSnapshot().TotalQueries.ShouldBe(0); } [Fact] public async Task The_bucket_count_is_capped_at_100000() { 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( () => 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 } }