using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; public sealed class BucketPlanTests { [Fact] public void BucketPlan_rejects_more_than_100k_buckets() { var ex = Should.Throw(() => BucketPlan.Create(DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(100_001), TimeSpan.FromSeconds(1))); ex.ParamName.ShouldBe("interval"); } [Fact] public void Exactly_100k_one_tick_buckets_are_allowed() { var p = BucketPlan.Create(At(0), At(0).AddTicks(100_000), TimeSpan.FromTicks(1)); p.BucketCount.ShouldBe(100_000); p.IntervalTicks.ShouldBe(1); } [Fact] public void A_partial_last_interval_is_its_own_bucket() { var p = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20)); p.BucketCount.ShouldBe(3); p.BucketTimestamp(2).ShouldBe(At(40)); p.BucketTimestamp(2).Kind.ShouldBe(DateTimeKind.Utc); p.IsTruncated(2).ShouldBeTrue(); p.IsTruncated(1).ShouldBeFalse(); } [Fact] public void Zero_or_oversized_interval_is_one_bucket_and_an_empty_window_none() { BucketPlan.Create(At(0), At(80), TimeSpan.Zero).BucketCount.ShouldBe(1); var wide = BucketPlan.Create(At(0), At(80), TimeSpan.FromDays(1)); wide.BucketCount.ShouldBe(1); wide.IntervalTicks.ShouldBe(80 * TimeSpan.TicksPerSecond); BucketPlan.Create(At(0), At(0), TimeSpan.FromSeconds(1)).BucketCount.ShouldBe(0); } [Fact] public void A_reverse_window_is_anchored_at_its_later_time_and_stamped_with_later_edges() { // #677 (Part 13 / SDK CreateSlice + GetTimestamp): At(80) → At(0), 20 s ⇒ (60,80], (40,60], (20,40], (0,20], // each stamped with its later edge, latest first. var p = BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)); p.Reversed.ShouldBeTrue(); p.StartUtc.ShouldBe(At(0)); p.EndUtc.ShouldBe(At(80)); var s = p.Assemble(new Dictionary(), t => new DataValueSnapshot(null, 0, t, t)); s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]); } [Fact] public void A_reverse_window_puts_the_partial_interval_at_its_earliest_end() { // At(50) → At(0), 20 s: (30,50], (10,30], (0,10] — the truncated bucket is the EARLIEST (the old layout truncated // the latest: [0,20), [20,40), [40,50)). var p = BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)); p.BucketCount.ShouldBe(3); p.LowerTicks(0).ShouldBe(At(0).Ticks); p.UpperTicks(0).ShouldBe(At(10).Ticks); p.IsTruncated(0).ShouldBeTrue(); p.LowerTicks(2).ShouldBe(At(30).Ticks); p.UpperTicks(2).ShouldBe(At(50).Ticks); p.IsTruncated(2).ShouldBeFalse(); var s = p.Assemble(new Dictionary(), t => new DataValueSnapshot(null, 0, t, t)); s.Select(x => x.SourceTimestampUtc).ShouldBe([At(50), At(30), At(10)]); } [Fact] public void Forward_buckets_are_start_inclusive_and_reverse_buckets_later_edge_inclusive() { var fwd = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20)); fwd.IndexOf(At(0)).ShouldBe(0); fwd.IndexOf(At(20)).ShouldBe(1); fwd.IndexOf(At(20).AddTicks(-1)).ShouldBe(0); fwd.IndexOf(At(49.9999999)).ShouldBe(2); fwd.IndexOf(At(50)).ShouldBeNull(); fwd.IndexOf(At(0).AddTicks(-1)).ShouldBeNull(); // (0,10] (10,30] (30,50]: the later time is in the window, the earlier one is not (as a reverse raw read). var rev = BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)); rev.IndexOf(At(50)).ShouldBe(2); rev.IndexOf(At(30)).ShouldBe(1); rev.IndexOf(At(30).AddTicks(1)).ShouldBe(2); rev.IndexOf(At(10)).ShouldBe(0); rev.IndexOf(At(10).AddTicks(1)).ShouldBe(1); rev.IndexOf(At(0).AddTicks(1)).ShouldBe(0); rev.IndexOf(At(0)).ShouldBeNull(); rev.IndexOf(At(50).AddTicks(1)).ShouldBeNull(); } [Fact] public void The_backend_bucket_formula_matches_IndexOf_in_both_directions() { // b = (t − start + adjust) / interval and b = (unixTicks(t) − base) / interval — what the SQL computes. foreach (var p in new[] { BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20)), BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)), BucketPlan.Create(At(60), At(0), TimeSpan.FromSeconds(20)), BucketPlan.Create(At(0).AddTicks(70), At(0), TimeSpan.FromTicks(3)), }) { for (var t = p.StartUtc.Ticks - 2; t <= p.EndUtc.Ticks + 2; t += Math.Max(1, (p.EndUtc.Ticks - p.StartUtc.Ticks) / 997)) { var at = new DateTime(t, DateTimeKind.Utc); if (p.IndexOf(at) is not { } idx) continue; ((t - p.StartUtc.Ticks + p.BucketIndexAdjustTicks) / p.IntervalTicks).ShouldBe(idx); (((at - DateTime.UnixEpoch).Ticks - p.BucketBaseUnixTicks) / p.IntervalTicks).ShouldBe(idx); p.LowerTicks(idx).ShouldBeLessThanOrEqualTo(t); p.UpperTicks(idx).ShouldBeGreaterThanOrEqualTo(t); } } } [Fact] public void Data_starts_inside_only_strictly_between_the_bucket_edges() { var p = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(20)); p.DataStartsInside(0, At(5)).ShouldBeTrue(); p.DataStartsInside(1, At(5)).ShouldBeFalse(); p.DataStartsInside(1, At(20)).ShouldBeFalse(); // on the edge: data covers the whole interval p.DataStartsInside(0, null).ShouldBeFalse(); p.DataStartsInside(0, At(-5)).ShouldBeFalse(); } [Fact] public void Data_ends_inside_from_the_interval_start_inclusive_to_its_end_exclusive() { var p = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(20)); p.DataEndsInside(0, At(5)).ShouldBeTrue(); p.DataEndsInside(1, At(20)).ShouldBeTrue(); // on the earlier edge: the SDK's m_endOfData >= startTime p.DataEndsInside(0, At(20)).ShouldBeFalse(); p.DataEndsInside(1, At(40)).ShouldBeFalse(); p.DataEndsInside(0, null).ShouldBeFalse(); // Reverse (40 → 0): (0,20], (20,40] — strictly inside only (the SDK's reverse m_startOfData test). var r = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(20)); r.DataEndsInside(1, At(30)).ShouldBeTrue(); r.DataEndsInside(1, At(40)).ShouldBeFalse(); r.DataEndsInside(1, At(20)).ShouldBeFalse(); r.DataEndsInside(0, At(20)).ShouldBeFalse(); } [Fact] public void The_data_range_includes_both_of_its_edges() { var p = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(20)); p.WithinDataRange(0, At(10), At(30)).ShouldBeTrue(); p.WithinDataRange(1, At(10), At(30)).ShouldBeTrue(); p.WithinDataRange(2, At(10), At(30)).ShouldBeFalse(); // [40,60) is after the last sample p.WithinDataRange(2, At(10), At(40)).ShouldBeTrue(); // last sample on the earlier edge p.WithinDataRange(0, At(20), At(30)).ShouldBeTrue(); // first sample on the later edge p.WithinDataRange(0, At(21), At(30)).ShouldBeFalse(); p.WithinDataRange(0, null, At(30)).ShouldBeFalse(); p.WithinDataRange(0, At(10), null).ShouldBeFalse(); } [Fact] public void The_data_range_is_needed_for_every_per_sample_aggregate_but_Average() { // Review F2 (#677) / #680: backends skip the earliest/latest-sample queries when their answer cannot change a // result — the start/end-of-data Partial rules are not Average's (end-of-data applies in reverse too); Count's in-range empty interval // (0, not BadNoData) applies in both directions. var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10)); var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10)); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, fwd).ShouldBeTrue(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, fwd).ShouldBeTrue(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Average, fwd).ShouldBeFalse(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, rev).ShouldBeTrue(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, rev).ShouldBeTrue(); // reverse end-of-data AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Average, rev).ShouldBeFalse(); AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, BucketPlan.Create(At(0), At(0), TimeSpan.Zero)) .ShouldBeFalse(); } [Fact] public void Assemble_fills_missing_buckets_with_the_empty_factory() { var p = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(20)); var hit = new DataValueSnapshot(7d, 0, At(20), At(20)); var s = p.Assemble(new Dictionary { [1] = hit }, t => new DataValueSnapshot(null, 1u, t, t)); s.Select(x => x.Value).ShouldBe([null, (object?)7d, null]); s[0].SourceTimestampUtc.ShouldBe(At(0)); s[2].SourceTimestampUtc.ShouldBe(At(40)); } [Fact] public void BucketBaseUnixTicks_is_the_start_forward() => BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.FromSeconds(1)) .BucketBaseUnixTicks.ShouldBe(1234567L); [Fact] public void StartUnixTicks_is_ticks_since_the_unix_epoch() => BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.Zero) .StartUnixTicks.ShouldBe(1234567L); }