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