Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs
T
Joseph Doherty b8344bb151 fix(historian): #680 lane B — reverse end-of-data Partial (strictly inside), drop dead per-sample Percent cases
Per-sample aggregates (all but Average) now set Partial on a reverse read's interval
that strictly contains the tag's latest sample — the SDK's reverse m_startOfData
(verified vs the SDK harness), matching lane A's time-weighted rule. NeedsDataRange
therefore applies in both directions for every per-sample aggregate but Average.
PercentGood/Bad left the per-sample evaluator (lane A moved them to the time-weighted
family); Evaluate refuses non-per-sample aggregates up front. Comments fixed.
2026-10-11 16:38:19 -04:00

205 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ArgumentOutOfRangeException>(() =>
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<long, DataValueSnapshot>(), 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<long, DataValueSnapshot>(), 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<long, DataValueSnapshot> { [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);
}