b8344bb151
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.
205 lines
9.8 KiB
C#
205 lines
9.8 KiB
C#
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);
|
||
}
|