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; /// Live value writer behaviour: round-trips, idempotent redelivery, first-wins duplicates, non-finite doubles. public abstract class LiveWriteConformance : ConformanceBase { // Non-ms-aligned: sub-millisecond ticks must round-trip exactly (to the backend's resolution). private static readonly DateTime T0 = new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc).AddTicks(1234567); [Fact] public async Task Every_kind_round_trips_with_exact_ticks_and_status() { var w = B.CreateValueWriter(); var r = B.ResolutionTicks; var dt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(77 * r); HistorizationValue[] vals = [ new(T0, HistorianValue.FromDouble(1.25), 0), new(T0.AddTicks(1 * r), HistorianValue.FromInt64(long.MaxValue), 0x40000000u), new(T0.AddTicks(2 * r), HistorianValue.FromBoolean(true), 0), new(T0.AddTicks(3 * r), HistorianValue.FromString("héllo"), 0), new(T0.AddTicks(4 * r), HistorianValue.FromDateTime(dt), 0), new(T0.AddTicks(5 * r), HistorianValue.Empty, 0x80310000u), ]; (await w.WriteLiveValuesAsync("tag.a", vals, CancellationToken.None)).ShouldBeTrue(); var rows = await B.ReadBackAsync("tag.a"); rows.Count.ShouldBe(6); for (var i = 0; i < 6; i++) rows[i].T.Ticks.ShouldBe(B.Round(vals[i].TimestampUtc!.Value).Ticks); rows.Select(x => x.V).ToList().ShouldBe(vals.Select(v => v.Value).ToList()); rows[1].Status.ShouldBe(0x40000000u); rows[5].Status.ShouldBe(0x80310000u); } [Fact] public async Task Writing_the_same_batch_twice_is_idempotent() { var w = B.CreateValueWriter(); HistorizationValue[] vals = [new(T0, HistorianValue.FromDouble(1), 0), new(T0.AddSeconds(1), HistorianValue.FromDouble(2), 0)]; (await w.WriteLiveValuesAsync("tag.b", vals, CancellationToken.None)).ShouldBeTrue(); (await w.WriteLiveValuesAsync("tag.b", vals, CancellationToken.None)).ShouldBeTrue(); // Overlapping batch: only the new row lands, the stored first value is untouched. (await w.WriteLiveValuesAsync("tag.b", [new(T0, HistorianValue.FromDouble(99), 0), new(T0.AddSeconds(2), HistorianValue.FromDouble(3), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await B.ReadBackAsync("tag.b"); rows.Count.ShouldBe(3); rows[0].V.Number.ShouldBe(1); } [Fact] public async Task Within_batch_duplicate_timestamps_do_not_fail_and_first_wins() { var w = B.CreateValueWriter(); (await w.WriteLiveValuesAsync("tag.c", [new(T0, HistorianValue.FromDouble(1), 0), new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await B.ReadBackAsync("tag.c"); rows.Count.ShouldBe(1); rows[0].V.Number.ShouldBe(1); } [Fact] public async Task Non_finite_doubles_store_as_Empty_forced_Bad_and_do_not_fail_the_write() { var w = B.CreateValueWriter(); var r = B.ResolutionTicks; (await w.WriteLiveValuesAsync("tag.d", [ new(T0, HistorianValue.FromDouble(double.NaN), 0x40000000u), new(T0.AddTicks(1 * r), HistorianValue.FromDouble(double.PositiveInfinity), 0), new(T0.AddTicks(2 * r), HistorianValue.FromDouble(7), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await B.ReadBackAsync("tag.d"); rows.Count.ShouldBe(3); rows[0].V.Kind.ShouldBe(HistorianValueKind.Empty); rows[0].Status.ShouldBe(HistorianStatusCodes.BadOutOfRange); // Uncertain NaN ⇒ Bad (I-1) rows[1].Status.ShouldBe(HistorianStatusCodes.BadOutOfRange); // Good +Inf ⇒ Bad (I-1) rows[2].V.Number.ShouldBe(7); } [Fact] public async Task Empty_batch_succeeds_without_a_call_and_null_timestamp_is_stamped() { var w = B.CreateValueWriter(); (await w.WriteLiveValuesAsync("tag.e", [], CancellationToken.None)).ShouldBeTrue(); (await w.WriteLiveValuesAsync("tag.e", [new(null, HistorianValue.FromInt64(5), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await B.ReadBackAsync("tag.e"); rows.Count.ShouldBe(1); (DateTime.UtcNow - rows[0].T).ShouldBeLessThan(TimeSpan.FromMinutes(1)); } [Fact] public async Task Tag_is_created_with_the_first_typed_values_data_type() { var w = B.CreateValueWriter(); await w.WriteLiveValuesAsync("tag.f", [new(T0, HistorianValue.Empty, 0x80310000u), new(T0.AddTicks(B.ResolutionTicks), HistorianValue.FromString("x"), 0)], CancellationToken.None); (await B.TagDataTypeAsync("tag.f")).ShouldBe(DriverDataType.String); } }