using System.Data; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests; /// /// Live-gate 8 F1 (defence in depth on SQL Server, where it was not observed): the per-sample aggregate row mapper /// () over a hand-built row in the /// column layout. A row whose counts include samples but whose /// first/last-of-any-quality picks are NULL used to throw out of ReadStored and fail the node's whole read; it now /// maps to absent picks the evaluator answers as BadNoData. /// public sealed class BucketRowMappingTests { private const uint NoDataCalc = 0x809B0401u; private static readonly DateTime T = new(2026, 10, 11, 12, 0, 0, DateTimeKind.Utc); /// b + 5 counts, two usable picks (kind, num, int, str, ts), two any picks (ts, kind, num, int, str, status). private static DataTable StartEndTable() { var t = new DataTable(); t.Columns.Add("b", typeof(long)); foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int)); for (var p = 0; p < 2; p++) { t.Columns.Add($"k{p}", typeof(byte)); t.Columns.Add($"n{p}", typeof(double)); t.Columns.Add($"i{p}", typeof(long)); t.Columns.Add($"s{p}", typeof(string)); t.Columns.Add($"t{p}", typeof(DateTime)); } for (var p = 0; p < 2; p++) { t.Columns.Add($"at{p}", typeof(DateTime)); t.Columns.Add($"ak{p}", typeof(byte)); t.Columns.Add($"an{p}", typeof(double)); t.Columns.Add($"ai{p}", typeof(long)); t.Columns.Add($"as{p}", typeof(string)); t.Columns.Add($"st{p}", typeof(int)); } return t; } private static DataRow Counts(DataTable t, long bucket, int total) { var row = t.NewRow(); row[0] = bucket; row[1] = total; row[2] = total; row[3] = total; row[4] = 0; row[5] = 0; return row; } private static BucketRow Map(DataTable t, AggregateFamily family) { using var r = t.CreateDataReader(); r.Read().ShouldBeTrue(); return SqlServerHistorianDataSource.ReadBucket(r, family); } [Fact] public void A_row_with_counts_but_null_first_and_last_of_any_quality_maps_to_absent_picks_without_throwing() { var t = StartEndTable(); t.Rows.Add(Counts(t, 3, 2)); var b = Map(t, AggregateFamily.StartEnd); b.Bucket.ShouldBe(3); b.Total.ShouldBe(2); b.FirstAny.ShouldBeNull(); b.LastAny.ShouldBeNull(); b.First.Kind.ShouldBe(HistorianValueKind.Empty); foreach (var agg in new[] { HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta }) AggregateEvaluator.Evaluate(agg, b, T, T).StatusCode.ShouldBe(NoDataCalc); } [Fact] public void A_pick_nulled_by_a_delete_racing_the_key_join_is_no_data_for_that_end_only() { // #683 join-back: the bucket's first sample (usable, so both first picks) was deleted between the GROUP BY and its // key seek — the counts still include it, its pick columns are NULL; the last picks joined normally. var t = StartEndTable(); var row = Counts(t, 0, 2); row["k1"] = (byte)1; row["n1"] = 20d; row["t1"] = T.AddSeconds(1); row["at1"] = T.AddSeconds(1); row["ak1"] = (byte)1; row["an1"] = 20d; row["st1"] = 0; t.Rows.Add(row); var b = Map(t, AggregateFamily.StartEnd); b.FirstAny.ShouldBeNull(); b.First.Kind.ShouldBe(HistorianValueKind.Empty); AggregateEvaluator.Evaluate(HistoryAggregateType.Start, b, T, T).StatusCode.ShouldBe(NoDataCalc); AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, b, T, T).StatusCode.ShouldBe(NoDataCalc); AggregateEvaluator.Evaluate(HistoryAggregateType.End, b, T, T).Value.ShouldBe(20d); } [Fact] public void A_consistent_row_still_maps_every_pick() { var t = StartEndTable(); var row = Counts(t, 0, 2); row["k0"] = (byte)1; row["n0"] = 10d; row["t0"] = T; row["k1"] = (byte)1; row["n1"] = 20d; row["t1"] = T.AddSeconds(1); row["at0"] = T; row["ak0"] = (byte)1; row["an0"] = 10d; row["st0"] = 0; row["at1"] = T.AddSeconds(1); row["ak1"] = (byte)1; row["an1"] = 20d; row["st1"] = 0; t.Rows.Add(row); var b = Map(t, AggregateFamily.StartEnd); b.First.ShouldBe(HistorianValue.FromDouble(10)); b.Last.ShouldBe(HistorianValue.FromDouble(20)); b.FirstAny.ShouldNotBeNull().TimeUtc.ShouldBe(T); b.LastAny.ShouldNotBeNull().Value.ShouldBe(HistorianValue.FromDouble(20)); AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, b, T, T).Value.ShouldBe(10d); } [Fact] public void An_actual_time_row_with_counts_but_no_extreme_is_no_data() { var t = new DataTable(); t.Columns.Add("b", typeof(long)); foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int)); t.Columns.Add("NumValue", typeof(double)); t.Columns.Add("SourceTimeUtc", typeof(DateTime)); t.Columns.Add("UncMin", typeof(double)); t.Columns.Add("UncMax", typeof(double)); t.Columns.Add("goodnonnum", typeof(int)); t.Rows.Add(0L, 1, 1, 1, 0, 0, DBNull.Value, DBNull.Value, 4d, DBNull.Value, 0); var b = Map(t, AggregateFamily.ActualTime); b.Extreme.ShouldBeNull(); AggregateEvaluator.Evaluate(HistoryAggregateType.MaximumActualTime, b, T, T).StatusCode.ShouldBe(NoDataCalc); } [Fact] public void A_simple_row_maps_the_good_value_columns_and_the_uncertain_extremes() { // #684: AvgValue/MinValue/MaxValue/SumValue are over Good samples; UncMin/UncMax (ordinals 10, 11) feed the Part 13 // Minimum/Maximum/Range status rule. var t = new DataTable(); t.Columns.Add("b", typeof(long)); foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int)); foreach (var c in new[] { "avg", "min", "max", "sum", "uncmin", "uncmax" }) t.Columns.Add(c, typeof(double)); t.Columns.Add("goodnonnum", typeof(int)); t.Rows.Add(0L, 4, 4, 2, 0, 1, 20d, 10d, 30d, 40d, 5d, 25d, 0); var b = Map(t, AggregateFamily.Simple); b.Avg.ShouldBe(20d); b.Min.ShouldBe(10d); b.Max.ShouldBe(30d); b.Sum.ShouldBe(40d); b.UncertainMin.ShouldBe(5d); b.UncertainMax.ShouldBe(25d); b.NonNumeric.ShouldBe(1); b.GoodNonNumeric.ShouldBe(0); // Uncertain 5 is below the Good minimum ⇒ Minimum Uncertain. The one non-numeric sample is Uncertain (ordinal 12 says // no Good one), so Average is served — not refused — and Maximum is Uncertain for it (25 alone is inside the range). AggregateEvaluator.Evaluate(HistoryAggregateType.Minimum, b, T, T).StatusCode.ShouldBe(0x40A40401u); AggregateEvaluator.Evaluate(HistoryAggregateType.Average, b, T, T).Value.ShouldBe(20d); AggregateEvaluator.Evaluate(HistoryAggregateType.Maximum, b, T, T).StatusCode.ShouldBe(0x40A40401u); } [Fact] public void An_actual_time_row_maps_the_uncertain_extremes_after_the_picked_sample() { var t = new DataTable(); t.Columns.Add("b", typeof(long)); foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(int)); t.Columns.Add("NumValue", typeof(double)); t.Columns.Add("SourceTimeUtc", typeof(DateTime)); t.Columns.Add("UncMin", typeof(double)); t.Columns.Add("UncMax", typeof(double)); t.Columns.Add("goodnonnum", typeof(int)); t.Rows.Add(0L, 3, 3, 2, 0, 0, 10d, T, 5d, 25d, 0); var b = Map(t, AggregateFamily.ActualTime); b.Extreme.ShouldBe(10d); b.ExtremeTime.ShouldBe(T); b.UncertainMin.ShouldBe(5d); b.UncertainMax.ShouldBe(25d); AggregateEvaluator.Evaluate(HistoryAggregateType.MinimumActualTime, b, T, T).StatusCode.ShouldBe(0x40A40401u); } }