using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; /// /// Direct tests of the aggregate evaluator over hand-built bucket rows for tag A, 20 s intervals from T0, window /// [t0, t0+80s): B0 = {10, 20}; B1 = {Bad 30, 40}; B2 = {50}; B3 empty. Literals from the SqlServer /// ProcessedReadTests. /// public sealed class AggregateEvaluatorTests { private static readonly DateTime Now = At(3600); private static Dictionary RowsA(bool maximum = false) => new() { [0] = new BucketRow { Bucket = 0, Total = 2, Usable = 2, Good = 2, Bad = 0, NonNumeric = 0, Avg = 15, Min = 10, Max = 20, Sum = 30, First = HistorianValue.FromDouble(10), Last = HistorianValue.FromDouble(20), Extreme = maximum ? 20 : 10, ExtremeTime = maximum ? At(10) : At(0), FirstAny = A[0], LastAny = A[1], }, [1] = new BucketRow { Bucket = 1, Total = 2, Usable = 1, Good = 1, Bad = 1, NonNumeric = 0, Avg = 40, Min = 40, Max = 40, Sum = 40, First = HistorianValue.FromDouble(40), Last = HistorianValue.FromDouble(40), Extreme = 40, ExtremeTime = At(30), FirstAny = A[2], LastAny = A[3], }, [2] = new BucketRow { Bucket = 2, Total = 1, Usable = 1, Good = 1, Bad = 0, NonNumeric = 0, Avg = 50, Min = 50, Max = 50, Sum = 50, First = HistorianValue.FromDouble(50), Last = HistorianValue.FromDouble(50), Extreme = 50, ExtremeTime = At(40), FirstAny = A[4], LastAny = A[4], }, }; private const uint Partial = 0x4u; private const uint BadCalc = 0x80000401u; // generic Bad | Calculated /// Tag A's data runs +0 … +40, so the forward read [0,80) holds the end of the data in B2 [40,60). private static IReadOnlyList ReadA(HistoryAggregateType agg, bool maximum = false) { var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20)); return AggregateEvaluator.EvaluateAll(agg, plan, RowsA(maximum).Values, At(0), At(40), Now); } [Theory] [InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)] [InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)] [InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)] [InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)] [InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)] public void Numeric_aggregates_over_hand_built_bucket_rows(HistoryAggregateType agg, double b0, double b1, double b2) { var s = ReadA(agg); s.Count.ShouldBe(4); s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]); // #680: B2 holds the last sample (+40) ⇒ end-of-data Partial on a forward read, for every aggregate but Average. // B1 opens on a Bad sample ⇒ Uncertain (for Delta: the interval's first sample was skipped). var b2Status = agg == HistoryAggregateType.Average ? GoodCalc : GoodCalc | Partial; s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, b2Status, NoDataCalc]); s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]); } [Fact] public void Start_and_End_return_the_raw_first_and_last_sample_of_any_quality() { // #680 (Part 13 Tables 73/74; SDK ComputeStartEnd): the raw sample — its own value (null when Bad), its own status // (Raw, no Calculated bit) and its own timestamp. B1's Start is the Bad sample at +20. var start = ReadA(HistoryAggregateType.Start); start.Select(x => x.Value).ShouldBe([10d, null, 50d, null]); start.Select(x => x.StatusCode).ShouldBe([Good, Bad, Good | 0x400u | Partial, NoDataCalc]); start.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]); var end = ReadA(HistoryAggregateType.End); end.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]); end.Select(x => x.StatusCode).ShouldBe([Good, Good, Good | 0x400u | Partial, NoDataCalc]); end.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]); } [Fact] public void Start_and_End_follow_the_sample_order_in_a_reverse_read() { // Reverse (80 → 0): (20,40] = {+30, +40}, (0,20] = {+10, +20 Bad}. Start is still the EARLIEST sample of each // interval and End the latest (the SDK's GetValues list is ascending in both directions). var plan = BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)); var rows = new[] { new BucketRow { Bucket = 1, Total = 2, Usable = 2, Good = 2, FirstAny = A[3], LastAny = A[4] }, new BucketRow { Bucket = 0, Total = 2, Usable = 1, Good = 1, Bad = 1, FirstAny = A[1], LastAny = A[2] }, }; var start = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Start, plan, rows, At(0), At(40), Now); start.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(30), At(10)]); start.Select(x => x.Value).ShouldBe([null, null, 40d, 20d]); var end = AggregateEvaluator.EvaluateAll(HistoryAggregateType.End, plan, rows, At(0), At(40), Now); end.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]); end.Select(x => x.Value).ShouldBe([null, null, 50d, null]); end.Select(x => x.StatusCode).ShouldBe([NoDataCalc, NoDataCalc, Good, Bad]); } [Fact] public void Delta_is_good_when_a_bad_sample_sits_mid_interval_and_uncertain_when_one_bounds_it() { // SDK ComputeDelta: Uncertain only when the first or last sample of the interval is skipped (not usable). var mid = new BucketRow { Total = 3, Usable = 2, Good = 2, Bad = 1, First = HistorianValue.FromDouble(1), Last = HistorianValue.FromDouble(4), FirstAny = S(0, HistorianValue.FromDouble(1), Good), LastAny = S(9, HistorianValue.FromDouble(4), Good), }; var d = AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, mid, At(0), Now); d.Value.ShouldBe(3d); d.StatusCode.ShouldBe(GoodCalc); mid.LastAny = S(9, HistorianValue.Empty, BadNoCommunication); AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, mid, At(0), Now).StatusCode.ShouldBe(UncertainCalc); // An Uncertain bound is used (TreatUncertainAsBad = false), so it does not degrade Delta. mid.LastAny = S(9, HistorianValue.FromDouble(4), Uncertain); AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, mid, At(0), Now).StatusCode.ShouldBe(GoodCalc); } [Fact] public void Count_counts_good_samples_as_int() { // #680 (Part 13 §5.4.3.21; SDK ComputeCount): Good samples only; value-based status; end-of-data Partial on B2. var s = ReadA(HistoryAggregateType.Count); s.Select(x => x.Value).ShouldBe([(object?)2, 1, 1, null]); s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc | Partial, NoDataCalc]); } [Fact] public void Count_excludes_uncertain_samples_and_an_all_bad_interval_is_generic_bad() { var unc = new BucketRow { Total = 3, Usable = 3, Good = 1, Bad = 0 }; var c = AggregateEvaluator.Evaluate(HistoryAggregateType.Count, unc, At(0), Now); c.Value.ShouldBe(1); c.StatusCode.ShouldBe(UncertainCalc); var bad = AggregateEvaluator.Evaluate(HistoryAggregateType.Count, new BucketRow { Total = 2, Bad = 2 }, At(0), Now); bad.Value.ShouldBeNull(); bad.StatusCode.ShouldBe(BadCalc); } [Fact] public void An_empty_interval_inside_the_data_range_counts_zero_and_outside_it_is_no_data() { // #680: forward [0,60) at 10 s over data +1 … +45: [10,20) and [30,40) hold no sample but lie inside // [earliest, latest] ⇒ 0, Good; [50,60) is after the last sample ⇒ BadNoData. var plan = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(10)); var rows = new[] { new BucketRow { Bucket = 0, Total = 1, Usable = 1, Good = 1 }, new BucketRow { Bucket = 2, Total = 1, Usable = 1, Good = 1 }, new BucketRow { Bucket = 4, Total = 1, Usable = 1, Good = 1 }, }; var s = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, plan, rows, At(1), At(45), Now); s.Select(x => x.Value).ShouldBe([(object?)1, 0, 1, 0, 1, null]); s.Select(x => x.StatusCode).ShouldBe( [GoodCalc | Partial, GoodCalc, GoodCalc, GoodCalc, GoodCalc | Partial, NoDataCalc]); s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(10), At(20), At(30), At(40), At(50)]); // Reverse (60 → 0): (40,50] is inside the range (its earlier edge +40 < +45); (50,60] is after it. The last sample // (+45) lies strictly inside (40,50] ⇒ Partial there; the first sample's interval is not flagged in reverse. var rev = BucketPlan.Create(At(60), At(0), TimeSpan.FromSeconds(10)); var r = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, rev, rows, At(1), At(45), Now); r.Select(x => x.Value).ShouldBe([null, (object?)1, 0, 1, 0, 1]); r.Select(x => x.StatusCode).ShouldBe([NoDataCalc, GoodCalc | Partial, GoodCalc, GoodCalc, GoodCalc, GoodCalc]); // Other per-sample aggregates keep BadNoData on an empty interval. AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, plan, rows, At(1), At(45), Now)[1].StatusCode .ShouldBe(NoDataCalc); // No data range known (an unknown tag / no sample near the window) ⇒ every empty interval is BadNoData. AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, plan, [], null, null, Now) .ShouldAllBe(x => x.StatusCode == NoDataCalc); } [Fact] public void End_of_data_Partial_includes_the_interval_start_forward_and_is_strict_in_reverse() { var row = new BucketRow { Bucket = 2, Total = 1, Usable = 1, Good = 1, Min = 3, Avg = 3 }; var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10)); // Latest sample exactly on the interval's earlier edge (+20) ⇒ Partial (SDK m_endOfData >= startTime). AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [row], At(-5), At(20), Now)[2].StatusCode .ShouldBe(GoodCalc | Partial); // Data past the window end ⇒ no end-of-data Partial. AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [row], At(-5), At(100), Now)[2].StatusCode .ShouldBe(GoodCalc); // Average never sets it. AggregateEvaluator.EvaluateAll(HistoryAggregateType.Average, fwd, [row], At(-5), At(25), Now)[2].StatusCode .ShouldBe(GoodCalc); // A Bad result never carries Partial (ours; the SDK sets it on a Bad result too). var bad = new BucketRow { Bucket = 2, Total = 1, Bad = 1 }; AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [bad], At(-5), At(25), Now)[2].StatusCode .ShouldBe(NoDataCalc); // Reverse (40 → 0; bucket 2 = (20,30], result index 1): the latest sample strictly inside ⇒ Partial (the SDK's // reverse m_startOfData); on the interval's later edge (+30) or its excluded earlier edge (+20) ⇒ nothing. var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10)); AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, rev, [row], At(-5), At(25), Now) .Select(x => x.StatusCode & Partial).ShouldBe([0u, Partial, 0u, 0u]); AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, rev, [row], At(-5), At(30), Now) .ShouldAllBe(x => (x.StatusCode & Partial) == 0); AggregateEvaluator.EvaluateAll(HistoryAggregateType.Average, rev, [row], At(-5), At(25), Now) .ShouldAllBe(x => (x.StatusCode & Partial) == 0); } [Fact] public void Actual_time_aggregates_stamp_the_sample_time() { var min = ReadA(HistoryAggregateType.MinimumActualTime); min.Select(x => x.Value).ShouldBe([10d, 40d, 50d, null]); min.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(30), At(40), At(60)]); min.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc | Partial, NoDataCalc]); var max = ReadA(HistoryAggregateType.MaximumActualTime, maximum: true); max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]); max.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]); } [Fact] public void A_usable_non_numeric_sample_makes_a_numeric_aggregate_invalid_inputs_but_not_count() { var row = new BucketRow { Total = 2, Usable = 2, Good = 2, NonNumeric = 1, Avg = 1, First = HistorianValue.FromString("x"), Last = HistorianValue.FromDouble(1) }; var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now); avg.Value.ShouldBeNull(); avg.StatusCode.ShouldBe(InvalidInputsCalc); AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now).Value.ShouldBe(2); AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, row, At(0), Now).StatusCode.ShouldBe(InvalidInputsCalc); } [Fact] public void A_bucket_with_only_bad_samples_is_no_data_except_for_count() { var row = new BucketRow { Total = 2, Usable = 0, Good = 0, Bad = 2 }; var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now); avg.Value.ShouldBeNull(); avg.StatusCode.ShouldBe(NoDataCalc); // PercentGood / PercentBad are duration-based (time-weighted family) since #680: not served here. Should.Throw(() => AggregateEvaluator.Evaluate(HistoryAggregateType.PercentBad, row, At(0), Now)); // #680: Count of an all-Bad interval is the generic Bad (the SDK's value-based status), not BadNoData. AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now).StatusCode.ShouldBe(BadCalc); } [Fact] public void An_unsupported_aggregate_throws_not_supported() { var ex = Should.Throw(() => AggregateEvaluator.Evaluate(HistoryAggregateType.TimeAverage, RowsA()[0], At(0), Now)); ex.Aggregate.ShouldBe(HistoryAggregateType.TimeAverage); } /// /// #684: a bucket of Good 10 and Good 30 plus the given Uncertain sample(s) (no Bad). The value columns are the Good /// values only, which is what the backends' SQL now computes (the good flag, not the usable one). /// private static BucketRow GoodTenThirtyPlusUncertain(params double[] uncertain) => new() { Total = 2 + uncertain.Length, Usable = 2 + uncertain.Length, Good = 2, Bad = 0, Avg = 20, Min = 10, Max = 30, Sum = 40, UncertainMin = uncertain.Length == 0 ? null : uncertain.Min(), UncertainMax = uncertain.Length == 0 ? null : uncertain.Max(), }; private static DataValueSnapshot Eval(HistoryAggregateType agg, BucketRow row) { if (agg is HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime) { row.Extreme = agg == HistoryAggregateType.MinimumActualTime ? row.Min : row.Max; row.ExtremeTime = At(5); } return AggregateEvaluator.Evaluate(agg, row, At(0), Now); } [Theory] // Uncertain 20 sits INSIDE the Good range [10, 30]: Part 13 (and the SDK's ComputeMinMax) leave Min/Max/Range Good; // Average is value-based (Good only when every sample is Good, the SDK's GetValueBasedStatusCode) => Uncertain. [InlineData(HistoryAggregateType.Average, new[] { 20d }, 20d, false)] [InlineData(HistoryAggregateType.Minimum, new[] { 20d }, 10d, true)] [InlineData(HistoryAggregateType.Maximum, new[] { 20d }, 30d, true)] [InlineData(HistoryAggregateType.Range, new[] { 20d }, 20d, true)] [InlineData(HistoryAggregateType.MinimumActualTime, new[] { 20d }, 10d, true)] [InlineData(HistoryAggregateType.MaximumActualTime, new[] { 20d }, 30d, true)] // Equal to an extreme: the Part 13 comparison is strict ("less than" / "greater than") => still Good. [InlineData(HistoryAggregateType.Minimum, new[] { 10d }, 10d, true)] [InlineData(HistoryAggregateType.Maximum, new[] { 30d }, 30d, true)] [InlineData(HistoryAggregateType.Range, new[] { 10d, 30d }, 20d, true)] // Below the Good minimum: Minimum / MinimumActualTime / Range Uncertain, Maximum untouched; the value is still the // Good extreme, never the Uncertain one (pre-#684 Minimum answered 5). [InlineData(HistoryAggregateType.Minimum, new[] { 5d }, 10d, false)] [InlineData(HistoryAggregateType.MinimumActualTime, new[] { 5d }, 10d, false)] [InlineData(HistoryAggregateType.Maximum, new[] { 5d }, 30d, true)] [InlineData(HistoryAggregateType.MaximumActualTime, new[] { 5d }, 30d, true)] [InlineData(HistoryAggregateType.Range, new[] { 5d }, 20d, false)] [InlineData(HistoryAggregateType.Average, new[] { 5d }, 20d, false)] // Above the Good maximum: the mirror. [InlineData(HistoryAggregateType.Maximum, new[] { 99d }, 30d, false)] [InlineData(HistoryAggregateType.MaximumActualTime, new[] { 99d }, 30d, false)] [InlineData(HistoryAggregateType.Minimum, new[] { 99d }, 10d, true)] [InlineData(HistoryAggregateType.MinimumActualTime, new[] { 99d }, 10d, true)] [InlineData(HistoryAggregateType.Range, new[] { 99d }, 20d, false)] public void Uncertain_samples_are_not_part_of_the_value_and_degrade_the_status_per_Part_13(HistoryAggregateType agg, double[] uncertain, double expected, bool good) { var v = Eval(agg, GoodTenThirtyPlusUncertain(uncertain)); v.Value.ShouldBe(expected); v.StatusCode.ShouldBe(good ? GoodCalc : UncertainCalc); } [Theory] [InlineData(HistoryAggregateType.Average)] [InlineData(HistoryAggregateType.Minimum)] [InlineData(HistoryAggregateType.Maximum)] [InlineData(HistoryAggregateType.Range)] [InlineData(HistoryAggregateType.MinimumActualTime)] [InlineData(HistoryAggregateType.MaximumActualTime)] public void Only_a_good_non_numeric_sample_refuses_a_good_value_aggregate(HistoryAggregateType agg) { // #684 review: an UNCERTAIN String/DateTime sample is outside the Good-value set — served (Uncertain), not refused. var uncertainString = GoodTenThirtyPlusUncertain(); uncertainString.Total = uncertainString.Usable = 3; uncertainString.NonNumeric = 1; uncertainString.GoodNonNumeric = 0; var v = Eval(agg, uncertainString); v.Value.ShouldNotBeNull(); v.StatusCode.ShouldBe(UncertainCalc); // A GOOD one still refuses the interval (the value set would hold a value with no numeric meaning). var goodString = GoodTenThirtyPlusUncertain(); goodString.Total = goodString.Usable = goodString.Good = 3; goodString.NonNumeric = 1; goodString.GoodNonNumeric = 1; Eval(agg, goodString).StatusCode.ShouldBe(InvalidInputsCalc); // A row that does not carry the Good count (null) keeps the stricter pre-#684 check: any usable one refuses. uncertainString.GoodNonNumeric = null; Eval(agg, uncertainString).StatusCode.ShouldBe(InvalidInputsCalc); } [Fact] public void Delta_still_refuses_any_usable_non_numeric_sample() { // Delta reads the first/last USABLE samples (#680), so an Uncertain String can be its input: unchanged by #684. var row = new BucketRow { Total = 2, Usable = 2, Good = 1, NonNumeric = 1, GoodNonNumeric = 0, First = HistorianValue.FromString("x"), Last = HistorianValue.FromDouble(1), FirstAny = A[0], LastAny = A[1], }; AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, row, At(0), Now).StatusCode.ShouldBe(InvalidInputsCalc); } [Theory] [InlineData(HistoryAggregateType.Minimum)] [InlineData(HistoryAggregateType.Maximum)] [InlineData(HistoryAggregateType.Range)] [InlineData(HistoryAggregateType.MinimumActualTime)] [InlineData(HistoryAggregateType.MaximumActualTime)] public void A_bad_sample_makes_the_min_max_family_uncertain_even_with_no_uncertain_value(HistoryAggregateType agg) { var row = GoodTenThirtyPlusUncertain(); row.Total = 3; row.Bad = 1; Eval(agg, row).StatusCode.ShouldBe(UncertainCalc); } [Theory] [InlineData(HistoryAggregateType.Average)] [InlineData(HistoryAggregateType.Minimum)] [InlineData(HistoryAggregateType.Maximum)] [InlineData(HistoryAggregateType.Range)] [InlineData(HistoryAggregateType.MinimumActualTime)] [InlineData(HistoryAggregateType.MaximumActualTime)] public void An_interval_holding_only_uncertain_values_is_no_data(HistoryAggregateType agg) { // #684 (Part 13: these aggregates are defined over Good values; the SDK returns GetNoDataValue when none is Good). // The SQL's Good-only value columns are NULL; the Uncertain extremes are not a substitute. var row = new BucketRow { Total = 2, Usable = 2, Good = 0, UncertainMin = 4, UncertainMax = 6 }; var v = AggregateEvaluator.Evaluate(agg, row, At(0), Now); v.Value.ShouldBeNull(); v.StatusCode.ShouldBe(NoDataCalc); // Count is unchanged by #684: 0 Good samples, value-based Uncertain. var count = AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now); count.Value.ShouldBe(0); count.StatusCode.ShouldBe(UncertainCalc); } // ---- Live-gate 8 F1: a row whose counts and picks disagree (a backend that read them from two table versions). /// Counts say B0 holds two Good samples, but none of the picked samples came back. private static BucketRow InconsistentRow() => new() { Bucket = 0, Total = 2, Usable = 2, Good = 2, Bad = 0, NonNumeric = 0, Avg = 15, Min = 10, Max = 20, Sum = 30, First = HistorianValue.Empty, Last = HistorianValue.Empty, Extreme = null, ExtremeTime = null, FirstAny = null, LastAny = null, }; [Theory] [InlineData(HistoryAggregateType.Start)] [InlineData(HistoryAggregateType.End)] [InlineData(HistoryAggregateType.Delta)] [InlineData(HistoryAggregateType.MinimumActualTime)] [InlineData(HistoryAggregateType.MaximumActualTime)] public void A_bucket_whose_picked_samples_are_missing_is_no_data_not_a_throw_or_a_valueless_good(HistoryAggregateType agg) { var v = AggregateEvaluator.Evaluate(agg, InconsistentRow(), At(0), Now); v.Value.ShouldBeNull(); v.StatusCode.ShouldBe(NoDataCalc); } [Fact] public void A_delta_whose_first_usable_pick_alone_is_missing_is_no_data_not_invalid_inputs() { var row = InconsistentRow(); row.Last = HistorianValue.FromDouble(20); row.FirstAny = A[0]; row.LastAny = A[1]; AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, row, At(0), Now).StatusCode.ShouldBe(NoDataCalc); } [Fact] public void An_inconsistent_bucket_does_not_fail_the_whole_read_and_its_neighbours_keep_their_values() { var rows = RowsA(); rows[0] = InconsistentRow(); var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20)); foreach (var agg in new[] { HistoryAggregateType.Start, HistoryAggregateType.Delta, HistoryAggregateType.MaximumActualTime }) { var s = AggregateEvaluator.EvaluateAll(agg, plan, rows.Values, At(0), At(40), Now); s.Count.ShouldBe(4); s[0].StatusCode.ShouldBe(NoDataCalc); s[2].Value.ShouldNotBeNull(); } } }