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();
+ }
+}