Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/HistoryUpdatePlannerTests.cs
T
Joseph Doherty f0700571e8 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
2026-10-11 01:35:51 -04:00

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