f0700571e8
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
112 lines
5.8 KiB
C#
112 lines
5.8 KiB
C#
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);
|
|
}
|
|
}
|