using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an ordered /// modification trail — the PRIOR value, the caller's principal, the Part 11 update type and the modification time — /// readable through . Refused items, live recorder writes and /// reads leave nothing. /// public abstract class ModifiedValueConformance : ConformanceBase { private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static DateTime T(int n) => T0.AddSeconds(n).AddTicks(1230); // µs-aligned so both backends store it exactly private static readonly DateTime Lo = T0.AddHours(-1); private static readonly DateTime Hi = T0.AddHours(1); private static readonly CancellationToken Ct = CancellationToken.None; private static readonly HistoryUpdateContext Editor = ConformanceData.Editor; private static HistorizationValue V(int n, double d, uint status = Good) => new(T(n), HistorianValue.FromDouble(d), status); private async Task> TrailAsync( string tag, DateTime? start = null, DateTime? end = null, uint max = 0, HistoryModifiedCursor? after = null) { using var src = B.CreateDataSource(); var r = await src.ReadModifiedAsync(tag, start ?? Lo, end ?? Hi, max, after, Ct); if (max == 0) r.HasMore.ShouldBeFalse(); return r.Values; } private static void ShouldBeNoPrior(HistoryModifiedValue m) { m.Value.Value.ShouldBeNull(); m.Value.StatusCode.ShouldBe(HistorianStatusCodes.BadNoData); } [Fact] public async Task Insert_records_one_Insert_per_sample_with_no_prior_value_and_the_principal() { var before = DateTime.UtcNow.AddSeconds(-5); var u = B.CreateUpdater(); (await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(2, 2), V(1, 1)], Editor, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]); var trail = await TrailAsync("t"); trail.Count.ShouldBe(2); trail.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]); // ordered by source time trail.ShouldAllBe(m => m.UpdateType == HistoryModificationType.Insert); trail.ShouldAllBe(m => m.UserName == "conformance-editor"); trail.ShouldAllBe(m => m.ModificationTimeUtc >= before && m.ModificationTimeUtc <= DateTime.UtcNow.AddSeconds(5)); foreach (var m in trail) ShouldBeNoPrior(m); } [Fact] public async Task Replace_records_the_prior_value_and_status_after_the_insert() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct); var other = new HistoryUpdateContext("second-editor", null); (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(1, 11, Uncertain)], other, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryReplaced]); (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(1, 111)], Editor, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryReplaced]); var trail = await TrailAsync("t"); trail.Select(m => m.UpdateType).ShouldBe( [HistoryModificationType.Insert, HistoryModificationType.Replace, HistoryModificationType.Replace]); trail.Select(m => m.UserName).ShouldBe(["conformance-editor", "second-editor", "conformance-editor"]); ShouldBeNoPrior(trail[0]); trail[1].Value.Value.ShouldBe(1d); // what the first Replace overwrote trail[1].Value.StatusCode.ShouldBe(Good); trail[2].Value.Value.ShouldBe(11d); // what the second Replace overwrote, with its status trail[2].Value.StatusCode.ShouldBe(Uncertain); trail.ShouldAllBe(m => m.Value.SourceTimestampUtc == T(1)); trail.Select(m => m.ModifiedId).ShouldBeInOrder(SortDirection.Ascending); trail.Select(m => m.ModifiedId).Distinct().Count().ShouldBe(3); // The sample itself carries the latest value. (await B.ReadBackAsync("t")).ShouldHaveSingleItem().V.Number.ShouldBe(111d); } [Fact] public async Task Update_records_Update_with_the_prior_value_when_it_replaced_and_none_when_it_inserted() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct); (await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(2, 2), V(1, 10)], Editor, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]); var trail = await TrailAsync("t"); trail.Select(m => (m.Value.SourceTimestampUtc, m.UpdateType)).ShouldBe( [ (T(1), HistoryModificationType.Insert), (T(1), HistoryModificationType.Update), (T(2), HistoryModificationType.Update), ]); trail[1].Value.Value.ShouldBe(1d); ShouldBeNoPrior(trail[2]); } [Fact] public async Task DeleteAtTime_records_a_Delete_carrying_each_deleted_value() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3, Uncertain)], Editor, Ct); var deleter = new HistoryUpdateContext("deleter", "k-del"); (await u.DeleteAtTimeAsync("t", [T(3), T(1), T(9)], deleter, Ct)) .ShouldBe([Good, Good, HistorianStatusCodes.BadNoEntryExists]); var deletes = (await TrailAsync("t")).Where(m => m.UpdateType == HistoryModificationType.Delete).ToList(); deletes.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(3)]); deletes.Select(m => (double)m.Value.Value!).ShouldBe([1d, 3d]); deletes[1].Value.StatusCode.ShouldBe(Uncertain); deletes.ShouldAllBe(m => m.UserName == "deleter"); } [Fact] public async Task DeleteRaw_records_a_Delete_for_every_sample_it_removed() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct); (await u.DeleteRawAsync("t", T(1), T(3), Editor, Ct)).ShouldBe(Good); // [T1, T3) var deletes = (await TrailAsync("t")).Where(m => m.UpdateType == HistoryModificationType.Delete).ToList(); deletes.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]); deletes.Select(m => (double)m.Value.Value!).ShouldBe([1d, 2d]); } [Fact] public async Task Refused_items_leave_no_trail() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct); (await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 99)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadEntryExists]); (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(5, 5)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]); (await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(null, HistorianValue.FromDouble(1), Good)], Editor, Ct)) .ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]); (await u.DeleteAtTimeAsync("t", [T(7)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]); (await u.DeleteRawAsync("t", T(10), T(20), Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData); var trail = await TrailAsync("t"); trail.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert); // only the first, applied insert } [Fact] public async Task Live_recorder_writes_leave_no_trail() { var w = B.CreateValueWriter(); (await w.WriteLiveValuesAsync("live", [new(T(1), HistorianValue.FromDouble(1), Good), new(T(2), HistorianValue.FromDouble(2), Good)], Ct)) .ShouldBeTrue(); (await B.ReadBackAsync("live")).Count.ShouldBe(2); // the writes landed … (await TrailAsync("live")).ShouldBeEmpty(); // … and recorded no modification } [Fact] public async Task An_unknown_tag_has_an_empty_trail_and_reads_create_no_tag() { (await TrailAsync("never")).ShouldBeEmpty(); (await B.TagExistsAsync("never")).ShouldBeFalse(); } [Fact] public async Task The_trail_pages_by_key_exactly_once_and_reports_HasMore() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct); await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 20)], Editor, Ct); await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 200)], Editor, Ct); var all = await TrailAsync("t"); all.Count.ShouldBe(5); // T1 ins | T2 ins, rep, rep | T3 ins var seen = new List(); HistoryModifiedCursor? cursor = null; using var src = B.CreateDataSource(); for (var page = 0; page < 10; page++) { var r = await src.ReadModifiedAsync("t", Lo, Hi, 2, cursor, Ct); seen.AddRange(r.Values.Select(m => m.ModifiedId)); if (!r.HasMore) break; r.Values.Count.ShouldBe(2); var last = r.Values[^1]; cursor = new HistoryModifiedCursor(last.Value.SourceTimestampUtc!.Value, last.ModifiedId); } seen.ShouldBe(all.Select(m => m.ModifiedId)); // a page boundary inside the T2 run neither repeats nor skips } [Fact] public async Task A_reverse_window_reads_the_trail_descending() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2)], Editor, Ct); await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 20)], Editor, Ct); var forward = await TrailAsync("t"); var reverse = await TrailAsync("t", start: Hi, end: Lo); reverse.Select(m => m.ModifiedId).ShouldBe(forward.Select(m => m.ModifiedId).Reverse()); } [Fact] public async Task The_window_is_half_open_on_source_time() { var u = B.CreateUpdater(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct); (await TrailAsync("t", start: T(1), end: T(3))).Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]); (await TrailAsync("t", start: T(2), end: T(2))).Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(2)]); } }