Merge feat/668-modified-values: Part 11 modified-value history (IsReadModified) on SqlServer + QuestDB (#668)

This commit is contained in:
Joseph Doherty
2026-10-11 09:32:13 -04:00
51 changed files with 2173 additions and 195 deletions
@@ -8,6 +8,9 @@ public static class ConformanceData
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567);
/// <summary>The HistoryUpdate caller the suite passes to every updater call (recorded in the modification trail).</summary>
public static readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryUpdateContext Editor = new("conformance-editor", "k-conf");
/// <summary>Good.</summary>
public const uint Good = 0x00000000u;
/// <summary>Bad (generic).</summary>
@@ -0,0 +1,217 @@
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;
/// <summary>
/// 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 <see cref="IHistorianDataSource.ReadModifiedAsync"/>. Refused items, live recorder writes and
/// reads leave nothing.
/// </summary>
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<IReadOnlyList<HistoryModifiedValue>> 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<long>();
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)]);
}
}
@@ -85,7 +85,7 @@ public abstract class QualityContractConformance : ConformanceBase
new(At(1), HistorianValue.Empty, 0x40000000u),
new(At(2), HistorianValue.Empty, BadNoCommunication),
new(At(3), HistorianValue.FromDouble(1), 0u),
], Ct);
], ConformanceData.Editor, Ct);
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.GoodEntryInserted]);
(await B.CreateDataSource().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
@@ -39,10 +39,10 @@ public abstract class TagNameConformance : ConformanceBase
var u = B.CreateUpdater();
(await u.UpdateDataAsync(Long, HistoryUpdateMode.Update,
[new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], Ct))
[new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], ConformanceData.Editor, Ct))
.ShouldBe([BadInvalidArgument, BadInvalidArgument]);
(await u.DeleteRawAsync(Long, At(-1), At(10), Ct)).ShouldBe(BadInvalidArgument);
(await u.DeleteAtTimeAsync(Long, [At(0)], Ct)).ShouldBe([BadInvalidArgument]);
(await u.DeleteRawAsync(Long, At(-1), At(10), ConformanceData.Editor, Ct)).ShouldBe(BadInvalidArgument);
(await u.DeleteAtTimeAsync(Long, [At(0)], ConformanceData.Editor, Ct)).ShouldBe([BadInvalidArgument]);
// The prefix series is untouched: not replaced, not deleted.
var raw = (await B.CreateDataSource().ReadRawAsync(Prefix, At(-1), At(10), 0, Ct)).Samples;
@@ -23,10 +23,10 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Insert_new_is_GoodEntryInserted_and_existing_is_BadEntryExists_in_order()
{
var u = B.CreateUpdater();
var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct);
var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], ConformanceData.Editor, Ct);
r1.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
// Mixed: T(2) exists, T(3) new, T(1) exists - out of timestamp order on purpose.
var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], Ct);
var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], ConformanceData.Editor, Ct);
r2.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.BadEntryExists]);
var rows = await B.ReadBackAsync("t");
rows.Select(x => (double?)x.V.Number).ShouldBe([1d, 2d, 3d]); // existing values untouched
@@ -37,9 +37,9 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Replace_existing_is_GoodEntryReplaced_with_new_value_and_status_missing_is_BadNoEntryExists()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], ConformanceData.Editor, Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], Ct);
[V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.GoodEntryReplaced]);
var rows = await B.ReadBackAsync("t");
rows.Count.ShouldBe(2); // T(5) was not created
@@ -53,8 +53,8 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Update_upserts_inserted_or_replaced_per_item()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]);
(await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([10d, 2d]);
}
@@ -63,15 +63,15 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Unknown_tag_Insert_and_Update_create_it_but_Replace_and_deletes_do_not()
{
var u = B.CreateUpdater();
(await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], Ct))
(await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], ConformanceData.Editor, Ct))
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("del", T(0), T(9), ConformanceData.Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteAtTimeAsync("del2", [T(1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
foreach (var name in new[] { "rep", "del", "del2" })
(await B.TagExistsAsync(name)).ShouldBeFalse(name); // none of those created a tag
(await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await B.TagExistsAsync("ins")).ShouldBeTrue();
(await B.TagExistsAsync("upd")).ShouldBeTrue();
}
@@ -81,14 +81,14 @@ public abstract class UpdateConformance : ConformanceBase
{
var u = B.CreateUpdater();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], Ct);
[V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadInvalidTimestamp]);
(await B.ReadBackAsync("t")).Count.ShouldBe(1);
// All-invalid request: no DB work, no tag created, still one code per item.
var u2 = B.CreateUpdater();
await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); // bootstraps the schema
(await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct); // bootstraps the schema
(await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
(await B.TagExistsAsync("seed")).ShouldBeTrue();
(await B.TagExistsAsync("x")).ShouldBeFalse(); // "x" was never created
}
@@ -98,7 +98,7 @@ public abstract class UpdateConformance : ConformanceBase
{
var u = B.CreateUpdater();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], Ct);
[V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument]);
(await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([2d]);
@@ -108,7 +108,7 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Duplicate_timestamp_in_one_Insert_request_second_is_BadEntryExists_first_wins()
{
var u = B.CreateUpdater();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]);
(await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([1d, 2d]);
}
@@ -117,12 +117,12 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Duplicate_timestamp_in_one_Replace_or_Update_request_last_wins_and_all_share_the_code()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct);
var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], ConformanceData.Editor, Ct);
rep.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryReplaced]);
(await B.ReadBackAsync("t")).Single().V.Number.ShouldBe(6d);
var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], Ct);
var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], ConformanceData.Editor, Ct);
upd.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
(await B.ReadBackAsync("t")).Last().V.Number.ShouldBe(8d);
}
@@ -131,9 +131,9 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Replace_and_Update_can_change_the_value_kind_and_clear_stale_columns()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], ConformanceData.Editor, Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
[new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
var rows = await B.ReadBackAsync("t");
rows.Single().V.ShouldBe(HistorianValue.FromString("hi")); // Number cleared (0), not left at 1.5
}
@@ -142,13 +142,13 @@ public abstract class UpdateConformance : ConformanceBase
public async Task DeleteRaw_is_half_open_Good_when_rows_removed_BadNoData_when_none()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], ConformanceData.Editor, Ct);
// [T(1), T(3)) removes T(1) and T(2); T(3) (the end) survives.
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(Good);
(await u.DeleteRawAsync("t", T(1), T(3), ConformanceData.Editor, Ct)).ShouldBe(Good);
(await B.ReadBackAsync("t")).Select(x => x.T).ShouldBe([B.Round(T(3))]);
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteRawAsync("t", T(1), T(3), ConformanceData.Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
// sub-ms boundary: a start one resolution unit after T(3) excludes it.
(await u.DeleteRawAsync("t", T(3).AddTicks(B.ResolutionTicks), T(3).AddTicks(100 * B.ResolutionTicks), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteRawAsync("t", T(3).AddTicks(B.ResolutionTicks), T(3).AddTicks(100 * B.ResolutionTicks), ConformanceData.Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await B.ReadBackAsync("t")).Count.ShouldBe(1);
}
@@ -156,8 +156,8 @@ public abstract class UpdateConformance : ConformanceBase
public async Task DeleteAtTime_reports_per_time_in_order_with_exact_tick_matching()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct);
var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(B.ResolutionTicks) /* near miss */, T(1), DateTime.MinValue], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], ConformanceData.Editor, Ct);
var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(B.ResolutionTicks) /* near miss */, T(1), DateTime.MinValue], ConformanceData.Editor, Ct);
r.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, Good, HistorianStatusCodes.BadInvalidTimestamp]);
(await B.ReadBackAsync("t")).Select(x => x.T).ShouldBe([B.Round(T(2))]);
}
@@ -166,8 +166,8 @@ public abstract class UpdateConformance : ConformanceBase
public async Task DeleteAtTime_duplicate_time_first_reports_Good_later_BadNoEntryExists()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
(await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct);
(await u.DeleteAtTimeAsync("t", [T(1), T(1)], ConformanceData.Editor, Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]);
(await B.ReadBackAsync("t")).ShouldBeEmpty();
}
}