diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs new file mode 100644 index 00000000..4e9270e5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AggregateEvaluatorTests.cs @@ -0,0 +1,122 @@ +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), + }, + [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), + }, + [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), + }, + }; + + private static IReadOnlyList ReadA(HistoryAggregateType agg, bool maximum = false) + { + var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20)); + var evaluated = RowsA(maximum).ToDictionary(kv => kv.Key, + kv => AggregateEvaluator.Evaluate(agg, kv.Value, plan.BucketStart(kv.Key), Now)); + return plan.Assemble(evaluated, t => AggregateEvaluator.NoData(t, Now)); + } + + [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 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]); + s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]); + s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]); + } + + [Fact] + public void Count_counts_usable_samples_as_int() + { + 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, NoDataCalc]); + } + + [Theory] + [InlineData(HistoryAggregateType.PercentGood, 100d, 50d, 100d)] + [InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)] + public void Percent_aggregates_count_every_sample_and_stay_good(HistoryAggregateType agg, double b0, double b1, double b2) + { + var s = ReadA(agg); + s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]); + s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, NoDataCalc]); + } + + [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, 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_the_percentages() + { + 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); + AggregateEvaluator.Evaluate(HistoryAggregateType.PercentBad, row, At(0), Now).Value.ShouldBe(100d); + } + + [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); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs new file mode 100644 index 00000000..5ab37704 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs @@ -0,0 +1,109 @@ +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 AtTime rules, with the literals of the SqlServer AtTimeReadTests. +public sealed class AtTimeEvaluatorTests +{ + private static readonly DateTime Now = At(3600); + + [Fact] + public void AtTime_inside_an_outage_returns_the_bad_neighbours_code_with_no_value() + { + var t0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc); + var prev = new StoredSample(t0, HistorianValue.Empty, 0x80310000u); + var next = new StoredSample(t0.AddSeconds(10), HistorianValue.FromDouble(5), 0); + var r = AtTimeEvaluator.Evaluate(t0.AddSeconds(5), prev, next, t0, t0.AddHours(1)); + r.StatusCode.ShouldBe(0x80310000u); + r.Value.ShouldBeNull(); + } + + [Fact] + public void A_bad_valued_previous_neighbour_keeps_its_code_and_drops_its_value() + { + var r = AtTimeEvaluator.Evaluate(At(25), A[2], A[3], Now, Now); + r.Value.ShouldBeNull(); // NOT 35 and NOT 30 + r.StatusCode.ShouldBe(0x80000000u); + r.SourceTimestampUtc.ShouldBe(At(25)); + } + + [Fact] + public void Exact_hit_returns_the_stored_value_and_status_unchanged() + { + var r = AtTimeEvaluator.Evaluate(At(20), A[2], A[3], At(21), Now); + r.Value.ShouldBe(30d); + r.StatusCode.ShouldBe(0x80000000u); + r.ServerTimestampUtc.ShouldBe(At(21)); + } + + [Fact] + public void No_previous_sample_is_BadNoData_with_no_value() + { + var r = AtTimeEvaluator.Evaluate(At(0).AddTicks(-1), null, A[0], Now, Now); + r.Value.ShouldBeNull(); + r.StatusCode.ShouldBe(0x809B0000u); + r.SourceTimestampUtc.ShouldBe(At(0).AddTicks(-1)); + } + + [Fact] + public void A_bad_next_neighbour_holds_the_previous_value_uncertain() + { + var r = AtTimeEvaluator.Evaluate(At(15), A[1], A[2], Now, Now); + r.Value.ShouldBe(20d); // NOT 25 + r.StatusCode.ShouldBe(UncertainInterpolated); + } + + [Fact] + public void Between_two_good_doubles_is_linearly_interpolated_good() + { + AtTimeEvaluator.Evaluate(At(5), A[0], A[1], Now, Now).Value.ShouldBe(15d); + AtTimeEvaluator.Evaluate(At(2.5), A[0], A[1], Now, Now).Value.ShouldBe(12.5d); + var r = AtTimeEvaluator.Evaluate(At(32.5), A[3], A[4], Now, Now); + r.Value.ShouldBe(42.5d); + r.StatusCode.ShouldBe(GoodInterpolated); + r.SourceTimestampUtc.ShouldBe(At(32.5)); + } + + [Fact] + public void An_uncertain_neighbour_makes_the_interpolation_uncertain() + { + var r = AtTimeEvaluator.Evaluate(At(5), S(0, HistorianValue.FromDouble(10), Uncertain), + S(10, HistorianValue.FromDouble(20), Good), Now, Now); + r.Value.ShouldBe(15d); + r.StatusCode.ShouldBe(UncertainInterpolated); + } + + [Fact] + public void Int64_is_interpolated_and_rounded_away_from_zero_keeping_its_type() + { + var p = S(0, HistorianValue.FromInt64(10), Good); + var n = S(10, HistorianValue.FromInt64(15), Good); + AtTimeEvaluator.Evaluate(At(5), p, n, Now, Now).Value.ShouldBe(13L); // 12.5 ⇒ 13 + AtTimeEvaluator.Evaluate(At(2), p, n, Now, Now).Value.ShouldBe(11L); + } + + [Fact] + public void After_the_last_sample_the_value_is_stepped_good_until_now_then_uncertain() + { + var now = At(42); + var held = AtTimeEvaluator.Evaluate(At(41), A[4], null, now, now); + held.Value.ShouldBe(50d); + held.StatusCode.ShouldBe(GoodInterpolated); + var future = AtTimeEvaluator.Evaluate(At(45), A[4], null, now, now); + future.Value.ShouldBe(50d); + future.StatusCode.ShouldBe(UncertainInterpolated); + } + + [Fact] + public void A_non_numeric_kind_is_stepped_with_the_previous_status_plus_interpolated() + { + var p = S(0, HistorianValue.FromString("a"), Uncertain); + var n = S(10, HistorianValue.FromString("b"), Good); + var r = AtTimeEvaluator.Evaluate(At(5), p, n, Now, Now); + r.Value.ShouldBe("a"); + r.StatusCode.ShouldBe(Uncertain | 0x402u); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs new file mode 100644 index 00000000..71ed9889 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs @@ -0,0 +1,71 @@ +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; + +public sealed class BucketPlanTests +{ + [Fact] + public void BucketPlan_rejects_more_than_100k_buckets() + { + var ex = Should.Throw(() => + BucketPlan.Create(DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(100_001), TimeSpan.FromSeconds(1))); + ex.ParamName.ShouldBe("interval"); + } + + [Fact] + public void Exactly_100k_one_tick_buckets_are_allowed() + { + var p = BucketPlan.Create(At(0), At(0).AddTicks(100_000), TimeSpan.FromTicks(1)); + p.BucketCount.ShouldBe(100_000); + p.IntervalTicks.ShouldBe(1); + } + + [Fact] + public void A_partial_last_interval_is_its_own_bucket() + { + var p = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20)); + p.BucketCount.ShouldBe(3); + p.BucketStart(2).ShouldBe(At(40)); + p.BucketStart(2).Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void Zero_or_oversized_interval_is_one_bucket_and_an_empty_window_none() + { + BucketPlan.Create(At(0), At(80), TimeSpan.Zero).BucketCount.ShouldBe(1); + var wide = BucketPlan.Create(At(0), At(80), TimeSpan.FromDays(1)); + wide.BucketCount.ShouldBe(1); + wide.IntervalTicks.ShouldBe(80 * TimeSpan.TicksPerSecond); + BucketPlan.Create(At(0), At(0), TimeSpan.FromSeconds(1)).BucketCount.ShouldBe(0); + } + + [Fact] + public void A_reverse_window_is_planned_forward_and_assembled_descending() + { + var p = BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)); + p.Reversed.ShouldBeTrue(); + p.StartUtc.ShouldBe(At(0)); + p.EndUtc.ShouldBe(At(80)); + var s = p.Assemble(new Dictionary(), t => new DataValueSnapshot(null, 0, t, t)); + s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]); + } + + [Fact] + public void Assemble_fills_missing_buckets_with_the_empty_factory() + { + var p = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(20)); + var hit = new DataValueSnapshot(7d, 0, At(20), At(20)); + var s = p.Assemble(new Dictionary { [1] = hit }, t => new DataValueSnapshot(null, 1u, t, t)); + s.Select(x => x.Value).ShouldBe([null, (object?)7d, null]); + s[0].SourceTimestampUtc.ShouldBe(At(0)); + s[2].SourceTimestampUtc.ShouldBe(At(40)); + } + + [Fact] + public void StartUnixTicks_is_ticks_since_the_unix_epoch() + => BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.Zero) + .StartUnixTicks.ShouldBe(1234567L); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/Fixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/Fixture.cs new file mode 100644 index 00000000..8c0a85f9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/Fixture.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; + +/// The SqlServer integration fixture's time base and tag A, as plain values (no database). +internal static class Fixture +{ + /// 2026-10-09 08:00:00.1234567 UTC — deliberately not millisecond-aligned (same as ReadTestDb.T0). + public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567); + + public const uint Good = 0x00000000u; + public const uint Bad = 0x80000000u; + public const uint BadNoCommunication = 0x80310000u; + public const uint Uncertain = 0x40000000u; + + // Hand-computed literals, copied from the SqlServer integration tests. + public const uint GoodInterpolated = 0x00000402u; + public const uint UncertainInterpolated = 0x40A40402u; + public const uint GoodCalc = 0x00000401u; + public const uint UncertainCalc = 0x40A40401u; + public const uint NoDataCalc = 0x809B0401u; + public const uint InvalidInputsCalc = 0x80D60401u; + + public static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond)); + + public static StoredSample S(double seconds, HistorianValue v, uint status) => new(At(seconds), v, status); + + /// Tag A: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double). + public static readonly StoredSample[] A = + [ + S(0, HistorianValue.FromDouble(10), Good), + S(10, HistorianValue.FromDouble(20), Good), + S(20, HistorianValue.FromDouble(30), Bad), + S(30, HistorianValue.FromDouble(40), Good), + S(40, HistorianValue.FromDouble(50), Good), + ]; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/HistoryUpdatePlannerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/HistoryUpdatePlannerTests.cs new file mode 100644 index 00000000..598c9a7c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/HistoryUpdatePlannerTests.cs @@ -0,0 +1,111 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.HistorianStatusCodes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; + +/// Direct tests of the HistoryUpdate result rules (literals from the SqlServer UpdateTests). +public sealed class HistoryUpdatePlannerTests +{ + private static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567); + + private static DateTime T(int s) => T0.AddSeconds(s); + + private static HistorizationValue V(DateTime? t, double v, uint status = 0) => new(t, HistorianValue.FromDouble(v), status); + + private static Dictionary Wrote(UpdatePlan p, UpsertOutcome o) => + p.Winners.ToDictionary(w => w.Row.TimeUtc.Ticks, _ => o); + + [Fact] + public void Insert_duplicates_keep_the_first_and_later_ones_are_BadEntryExists() + { + var plan = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(1), 2), V(T(2), 3)]); + plan.Winners.Select(w => w.Index).ShouldBe([0, 2]); + plan.Winners[0].Row.Value.Number.ShouldBe(1); + HistoryUpdatePlanner.Complete(plan, HistoryUpdateMode.Insert, Wrote(plan, UpsertOutcome.Inserted)) + .ShouldBe([GoodEntryInserted, BadEntryExists, GoodEntryInserted]); + } + + [Fact] + public void Replace_duplicates_last_wins_and_every_occurrence_shares_its_code() + { + var plan = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Replace, [V(T(1), 1), V(T(2), 5), V(T(1), 2)]); + plan.Winners.Select(w => w.Index).ShouldBe([1, 2]); + var done = HistoryUpdatePlanner.Complete(plan, HistoryUpdateMode.Replace, + new Dictionary { [T(1).Ticks] = UpsertOutcome.Replaced }); + done.ShouldBe([GoodEntryReplaced, BadNoEntryExists, GoodEntryReplaced]); + } + + [Fact] + public void An_unwritten_winner_is_BadEntryExists_for_Insert_and_BadNoEntryExists_for_Replace() + { + var ins = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Insert, [V(T(1), 1)]); + HistoryUpdatePlanner.Complete(ins, HistoryUpdateMode.Insert, new Dictionary()).ShouldBe([BadEntryExists]); + var rep = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Replace, [V(T(1), 1)]); + HistoryUpdatePlanner.Complete(rep, HistoryUpdateMode.Replace, new Dictionary()).ShouldBe([BadNoEntryExists]); + } + + [Fact] + public void Invalid_items_are_refused_and_the_rest_proceed() + { + var plan = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Insert, + [ + V(null, 1), V(DateTime.MinValue, 1), V(T(1), double.NaN), + new(T(2), HistorianValue.Empty, 0u), // Empty with a Good status + new(T(3), HistorianValue.Empty, 0x80310000u), // Empty with a Bad status: an outage marker, accepted + V(T(4), 4), + ]); + plan.Results.ShouldBe([BadInvalidTimestamp, BadInvalidTimestamp, BadInvalidArgument, BadInvalidArgument, null, null]); + plan.Winners.Select(w => w.Index).ShouldBe([4, 5]); + } + + [Fact] + public void A_tag_name_over_400_characters_refuses_every_item_and_sends_nothing() + { + var plan = HistoryUpdatePlanner.Plan(new string('x', 401), HistoryUpdateMode.Update, [V(T(1), 1), V(T(2), 2)]); + plan.Winners.ShouldBeEmpty(); + HistoryUpdatePlanner.Complete(plan, HistoryUpdateMode.Update, new Dictionary()) + .ShouldBe([BadInvalidArgument, BadInvalidArgument]); + HistoryUpdatePlanner.Plan(new string('x', 400), HistoryUpdateMode.Update, [V(T(1), 1)]).Winners.Count.ShouldBe(1); + } + + [Fact] + public void Resolution_10_truncates_before_dedup_and_the_superseded_duplicate_shares_the_winners_code() + { + // T0 ends in …567 ticks: +2 (…569) lands in the same microsecond (…560); +3 would cross into the next. + var plan = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Update, [V(T0, 1), V(T0.AddTicks(2), 2)], resolutionTicks: 10); + plan.Winners.Count.ShouldBe(1); + plan.Winners[0].Index.ShouldBe(1); // last wins + plan.Winners[0].Row.TimeUtc.Ticks.ShouldBe(T0.Ticks - T0.Ticks % 10); // truncated + plan.Winners[0].Row.Value.Number.ShouldBe(2); + HistoryUpdatePlanner.Complete(plan, HistoryUpdateMode.Update, Wrote(plan, UpsertOutcome.Inserted)) + .ShouldBe([GoodEntryInserted, GoodEntryInserted]); + } + + [Fact] + public void Resolution_10_Insert_duplicates_after_truncation_are_BadEntryExists() + { + var plan = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddTicks(2), 2)], resolutionTicks: 10); + HistoryUpdatePlanner.Complete(plan, HistoryUpdateMode.Insert, Wrote(plan, UpsertOutcome.Inserted)) + .ShouldBe([GoodEntryInserted, BadEntryExists]); + } + + [Fact] + public void DeleteAtTime_first_occurrence_reports_the_outcome_and_minvalue_is_an_invalid_timestamp() + { + IReadOnlyList times = [T(1), T(1), DateTime.MinValue, T(2)]; + HistoryUpdatePlanner.DeleteAtTimeResults(times, new HashSet { T(1).Ticks }, tagKnown: true) + .ShouldBe([Good, BadNoEntryExists, BadInvalidTimestamp, BadNoEntryExists]); + HistoryUpdatePlanner.DeleteAtTimeResults(times, new HashSet { T(1).Ticks }, tagKnown: false) + .ShouldBe([BadNoEntryExists, BadNoEntryExists, BadInvalidTimestamp, BadNoEntryExists]); + } + + [Fact] + public void DeleteRaw_is_good_only_for_a_known_tag_with_deleted_rows() + { + HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, 3).ShouldBe(Good); + HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, 0).ShouldBe(BadNoData); + HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 5).ShouldBe(BadNoData); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/LiveWritePreparerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/LiveWritePreparerTests.cs new file mode 100644 index 00000000..258bfb61 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/LiveWritePreparerTests.cs @@ -0,0 +1,37 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; + +public sealed class LiveWritePreparerTests +{ + private static readonly DateTime T = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567); + + [Fact] + public void Prepare_truncates_to_microseconds_before_dedup_when_resolution_is_10() + { + // The brief's example used AddTicks(3), but …567 + 3 = …570 crosses into the NEXT microsecond; +2 (…569) stays + // in the same one (…560), which is the case the test means to pin. + var t = T; + var rows = LiveWritePreparer.Prepare([new(t, HistorianValue.FromDouble(1), 0), new(t.AddTicks(2), HistorianValue.FromDouble(2), 0)], () => t, 10); + rows.Count.ShouldBe(1); + rows[0].TimeUtc.Ticks.ShouldBe(t.Ticks - t.Ticks % 10); + rows[0].Value.Number.ShouldBe(1); + } + + [Fact] + public void Resolution_1_keeps_100ns_ticks_distinct() + { + var rows = LiveWritePreparer.Prepare([new(T, HistorianValue.FromDouble(1), 0), new(T.AddTicks(3), HistorianValue.FromDouble(2), 0)], () => T); + rows.Select(r => r.TimeUtc.Ticks).ShouldBe([T.Ticks, T.Ticks + 3]); + } + + [Fact] + public void Truncation_also_applies_to_the_clock_and_the_result_is_utc() + { + var rows = LiveWritePreparer.Prepare([new(null, HistorianValue.FromDouble(1), 0)], () => T, 10); + rows[0].TimeUtc.ShouldBe(new DateTime(T.Ticks - 7, DateTimeKind.Utc)); + rows[0].TimeUtc.Kind.ShouldBe(DateTimeKind.Utc); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SharedHelperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SharedHelperTests.cs new file mode 100644 index 00000000..bc15f86b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SharedHelperTests.cs @@ -0,0 +1,67 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; + +/// EventContinuation, HistorianHealthCounters, ClampSeverity and the StoredSample quality clamp. +public sealed class SharedHelperTests +{ + [Fact] + public void Event_continuation_is_ticks_pipe_event_id() + { + var t = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567); + EventContinuation.Encode(t, "abc").ShouldBe($"{t.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)}|abc"); + EventContinuation.Encode(new DateTime(42), "id").ShouldBe("42|id"); + } + + [Theory] + [InlineData(-5, 0)] + [InlineData(700, 700)] + [InlineData(70_000, 65_535)] + public void Event_severity_is_clamped_to_ushort(int stored, int expected) => + HistoricalEventRows.ClampSeverity(stored).ShouldBe((ushort)expected); + + [Fact] + public void Health_counters_open_only_after_a_success_with_no_failure_since() + { + var h = new HistorianHealthCounters(); + h.Snapshot().ProcessConnectionOpen.ShouldBeFalse(); + h.RecordSuccess(); + h.Snapshot().ProcessConnectionOpen.ShouldBeTrue(); + h.RecordFailure(new InvalidOperationException("boom")); + var s = h.Snapshot(); + s.ProcessConnectionOpen.ShouldBeFalse(); + s.EventConnectionOpen.ShouldBeFalse(); + s.ConsecutiveFailures.ShouldBe(1); + s.TotalQueries.ShouldBe(2); + s.LastError.ShouldBe("InvalidOperationException: boom"); + h.RecordSuccess(); + h.Snapshot().ConsecutiveFailures.ShouldBe(0); + h.Snapshot().ProcessConnectionOpen.ShouldBeTrue(); + } + + [Fact] + public void A_cancellation_is_not_a_health_failure() + { + var h = new HistorianHealthCounters(); + h.RecordFailure(new OperationCanceledException()); + h.RecordFailure(new TaskCanceledException()); + var s = h.Snapshot(); + s.TotalQueries.ShouldBe(0); + s.TotalFailures.ShouldBe(0); + s.LastError.ShouldBeNull(); + } + + [Fact] + public void A_stored_empty_sample_with_a_non_bad_status_is_served_BadNoData() + { + var t = DateTime.UnixEpoch; + StoredSample.FromStored(t, HistorianValue.Empty, 0u).Status.ShouldBe(0x809B0000u); + StoredSample.FromStored(t, HistorianValue.Empty, 0x40000000u).Status.ShouldBe(0x809B0000u); + StoredSample.FromStored(t, HistorianValue.Empty, 0x80310000u).Status.ShouldBe(0x80310000u); + StoredSample.FromStored(t, HistorianValue.FromDouble(1), 0x40000000u).Status.ShouldBe(0x40000000u); + StoredSample.FromStored(t, HistorianValue.Empty, 0x80310000u).IsUsable.ShouldBeFalse(); + StoredSample.FromStored(t, HistorianValue.FromDouble(1), 0x40000000u).IsGood.ShouldBeFalse(); + } +}