test(historian): direct Sql.Common evaluator tests (1b)
51 new test cases (34 methods) in Sql.Common.Tests covering AtTimeEvaluator, AggregateEvaluator (hand-built tag-A bucket rows, ProcessedRead literals), BucketPlan, LiveWritePreparer and HistoryUpdatePlanner (incl. resolutionTicks=10 truncate-before-dedup and the superseded-duplicate outcome), plus EventContinuation, HistorianHealthCounters, ClampSeverity and StoredSample.FromStored. Each was falsified by reverting the rule it pins (37 mutations, all red, all restored). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+122
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>ProcessedReadTests</c>.
|
||||
/// </summary>
|
||||
public sealed class AggregateEvaluatorTests
|
||||
{
|
||||
private static readonly DateTime Now = At(3600);
|
||||
|
||||
private static Dictionary<long, BucketRow> 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<DataValueSnapshot> 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<HistoryAggregateNotSupportedException>(() =>
|
||||
AggregateEvaluator.Evaluate(HistoryAggregateType.TimeAverage, RowsA()[0], At(0), Now));
|
||||
ex.Aggregate.ShouldBe(HistoryAggregateType.TimeAverage);
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
|
||||
/// <summary>Direct tests of the AtTime rules, with the literals of the SqlServer <c>AtTimeReadTests</c>.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentOutOfRangeException>(() =>
|
||||
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<long, DataValueSnapshot>(), 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<long, DataValueSnapshot> { [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);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
||||
|
||||
/// <summary>The SqlServer integration fixture's time base and tag A, as plain values (no database).</summary>
|
||||
internal static class Fixture
|
||||
{
|
||||
/// <summary>2026-10-09 08:00:00.1234567 UTC — deliberately not millisecond-aligned (same as ReadTestDb.T0).</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Tag A: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double).</summary>
|
||||
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),
|
||||
];
|
||||
}
|
||||
+111
@@ -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;
|
||||
|
||||
/// <summary>Direct tests of the HistoryUpdate result rules (literals from the SqlServer <c>UpdateTests</c>).</summary>
|
||||
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<long, UpsertOutcome> 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<long, UpsertOutcome> { [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<long, UpsertOutcome>()).ShouldBe([BadEntryExists]);
|
||||
var rep = HistoryUpdatePlanner.Plan("t", HistoryUpdateMode.Replace, [V(T(1), 1)]);
|
||||
HistoryUpdatePlanner.Complete(rep, HistoryUpdateMode.Replace, new Dictionary<long, UpsertOutcome>()).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<long, UpsertOutcome>())
|
||||
.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<DateTime> times = [T(1), T(1), DateTime.MinValue, T(2)];
|
||||
HistoryUpdatePlanner.DeleteAtTimeResults(times, new HashSet<long> { T(1).Ticks }, tagKnown: true)
|
||||
.ShouldBe([Good, BadNoEntryExists, BadInvalidTimestamp, BadNoEntryExists]);
|
||||
HistoryUpdatePlanner.DeleteAtTimeResults(times, new HashSet<long> { 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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
|
||||
/// <summary>EventContinuation, HistorianHealthCounters, ClampSeverity and the StoredSample quality clamp.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user