Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs
T

303 lines
11 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 ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi;
/// <summary>Tests for <see cref="KpiSeriesBucketer"/>.</summary>
public class KpiSeriesBucketerTests
{
// Fixed epoch used throughout so tests are fully deterministic.
private static readonly DateTime Epoch =
new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc);
private static DateTime T(int minutesFromEpoch) =>
Epoch.AddMinutes(minutesFromEpoch);
// -----------------------------------------------------------------------
// Guard / invalid-argument contracts
// -----------------------------------------------------------------------
[Fact]
public void Bucket_MaxPointsLessThan2_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 1));
}
[Fact]
public void Bucket_MaxPointsZero_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 0));
}
[Fact]
public void Bucket_ToUtcEqualFromUtc_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(30), T(30), maxPoints: 5));
}
[Fact]
public void Bucket_ToUtcBeforeFromUtc_ThrowsArgumentOutOfRangeException()
{
var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };
Assert.Throws<ArgumentOutOfRangeException>(() =>
KpiSeriesBucketer.Bucket(raw, T(60), T(0), maxPoints: 5));
}
// -----------------------------------------------------------------------
// Empty / null raw series
// -----------------------------------------------------------------------
[Fact]
public void Bucket_NullRaw_ReturnsEmptyList()
{
var result = KpiSeriesBucketer.Bucket(null!, T(0), T(60), maxPoints: 10);
Assert.Empty(result);
}
[Fact]
public void Bucket_EmptyRaw_ReturnsEmptyList()
{
var result = KpiSeriesBucketer.Bucket(
Array.Empty<KpiSeriesPoint>(), T(0), T(60), maxPoints: 10);
Assert.Empty(result);
}
// -----------------------------------------------------------------------
// raw.Count <= maxPoints → returned unchanged (same reference)
// -----------------------------------------------------------------------
[Fact]
public void Bucket_RawCountEqualsMaxPoints_ReturnsSameReference()
{
var raw = new[]
{
new KpiSeriesPoint(T(0), 1.0),
new KpiSeriesPoint(T(10), 2.0),
new KpiSeriesPoint(T(20), 3.0),
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
Assert.Same(raw, result);
}
[Fact]
public void Bucket_RawCountLessThanMaxPoints_ReturnsSameReference()
{
var raw = new[]
{
new KpiSeriesPoint(T(0), 5.0),
new KpiSeriesPoint(T(5), 6.0),
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 10);
Assert.Same(raw, result);
}
[Fact]
public void Bucket_SinglePoint_ReturnsSameReference()
{
var raw = new[] { new KpiSeriesPoint(T(30), 42.0) };
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5);
Assert.Same(raw, result);
}
// -----------------------------------------------------------------------
// Downsampling: correct bucket count and last-value selection
// -----------------------------------------------------------------------
[Fact]
public void Bucket_MorePointsThanMaxPoints_ReducesToMaxPointsBuckets()
{
// 60-minute window / 3 buckets → 20 min each.
// Plant exactly one point in each bucket; all three buckets occupied.
var raw = new[]
{
new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 20)
new KpiSeriesPoint(T(25), 20.0), // bucket 1: [20, 40)
new KpiSeriesPoint(T(45), 30.0), // bucket 2: [40, 60]
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
Assert.Equal(3, result.Count);
Assert.Equal(10.0, result[0].Value);
Assert.Equal(20.0, result[1].Value);
Assert.Equal(30.0, result[2].Value);
}
[Fact]
public void Bucket_MultiplePointsInSameBucket_LastValueWins()
{
// 60-minute window / 2 buckets → 30 min each.
// Three points all land in bucket 0; the last (T(25)) should win.
var raw = new[]
{
new KpiSeriesPoint(T(5), 1.0),
new KpiSeriesPoint(T(15), 2.0),
new KpiSeriesPoint(T(25), 99.0), // latest in bucket 0 → wins
new KpiSeriesPoint(T(35), 5.0), // bucket 1
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
Assert.Equal(99.0, result[0].Value); // last in bucket 0
Assert.Equal(5.0, result[1].Value); // only point in bucket 1
}
[Fact]
public void Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp()
{
// 60-minute window / 3 buckets → 20 min each.
// Output BucketStartUtc must be the bucket boundary, not the raw point's time.
// raw.Count (4) > maxPoints (3) ensures the downsampling path runs.
var raw = new[]
{
new KpiSeriesPoint(T(7), 1.0), // inside bucket 0: [0, 20)
new KpiSeriesPoint(T(22), 2.0), // inside bucket 1: [20, 40)
new KpiSeriesPoint(T(35), 3.0), // inside bucket 1: later → wins bucket 1
new KpiSeriesPoint(T(55), 4.0), // inside bucket 2: [40, 60]
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
// Bucket boundaries: 0 min, 20 min, 40 min
Assert.Equal(3, result.Count);
Assert.Equal(T(0), result[0].BucketStartUtc);
Assert.Equal(T(20), result[1].BucketStartUtc);
Assert.Equal(T(40), result[2].BucketStartUtc);
}
// -----------------------------------------------------------------------
// Right-edge: point exactly at toUtc lands in the last bucket
// -----------------------------------------------------------------------
[Fact]
public void Bucket_PointAtToUtc_LandsInLastBucket()
{
// 60-minute window / 3 buckets → 20 min each.
// A point exactly at T(60) = toUtc must go to bucket 2, not overflow.
var raw = new[]
{
new KpiSeriesPoint(T(10), 1.0),
new KpiSeriesPoint(T(30), 2.0),
new KpiSeriesPoint(T(60), 99.0), // right edge — must land in last bucket
};
// raw.Count (3) == maxPoints (3) so it normally returns as-is;
// use maxPoints=2 to force downsampling and expose the edge behaviour.
// Window: [T(0), T(60)], 2 buckets → 30 min each.
// T(10) → bucket 0, T(30) → bucket 1, T(60) → bucket 1 (last).
var raw2 = new[]
{
new KpiSeriesPoint(T(10), 5.0),
new KpiSeriesPoint(T(35), 6.0),
new KpiSeriesPoint(T(60), 7.0), // exactly toUtc → bucket 1
};
var result = KpiSeriesBucketer.Bucket(raw2, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
// Bucket 1 holds both T(35) and T(60); T(60) is later → wins.
Assert.Equal(7.0, result[1].Value);
}
// -----------------------------------------------------------------------
// Empty buckets omitted — no gap-filling
// -----------------------------------------------------------------------
[Fact]
public void Bucket_GapInRaw_EmptyBucketsOmitted()
{
// 60-minute window / 4 buckets → 15 min each.
// Populate only buckets 0 and 3; buckets 1 and 2 are empty.
// Expect 2 output points, not 4.
var raw = new[]
{
new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 15)
new KpiSeriesPoint(T(50), 20.0), // bucket 3: [45, 60]
};
// raw.Count (2) < maxPoints (4), so normally returns same reference.
// To test the gap-omission path we need raw.Count > maxPoints and a gap.
var raw2 = new[]
{
new KpiSeriesPoint(T(5), 10.0),
new KpiSeriesPoint(T(6), 11.0),
new KpiSeriesPoint(T(50), 20.0),
new KpiSeriesPoint(T(51), 21.0),
};
// 4 raw points, maxPoints=4 → returns same reference (no downsampling).
// Use maxPoints=3 to trigger the downsampler.
// 60-minute window / 3 buckets → 20 min each.
// T(5) → bucket 0, T(6) → bucket 0, T(50) → bucket 2, T(51) → bucket 2.
// Bucket 1 is empty → 2 output points.
var result = KpiSeriesBucketer.Bucket(raw2, T(0), T(60), maxPoints: 3);
Assert.Equal(2, result.Count);
Assert.Equal(11.0, result[0].Value); // last in bucket 0
Assert.Equal(21.0, result[1].Value); // last in bucket 2
}
// -----------------------------------------------------------------------
// Points outside [fromUtc, toUtc] are ignored
// -----------------------------------------------------------------------
[Fact]
public void Bucket_PointsOutsideWindow_AreIgnored()
{
// Window [T(10), T(50)], 2 buckets.
// Points at T(0) and T(60) are outside — should not appear in output.
var raw = new[]
{
new KpiSeriesPoint(T(0), 999.0), // before window
new KpiSeriesPoint(T(20), 1.0), // inside bucket 0: [T(10), T(30))
new KpiSeriesPoint(T(40), 2.0), // inside bucket 1: [T(30), T(50)]
new KpiSeriesPoint(T(60), 999.0), // after window
};
var result = KpiSeriesBucketer.Bucket(raw, T(10), T(50), maxPoints: 2);
Assert.Equal(2, result.Count);
Assert.Equal(1.0, result[0].Value);
Assert.Equal(2.0, result[1].Value);
}
// -----------------------------------------------------------------------
// Two-point minimum — maxPoints == 2 works correctly
// -----------------------------------------------------------------------
[Fact]
public void Bucket_MaxPointsExactly2_ProducesAtMostTwoBuckets()
{
var raw = Enumerable
.Range(0, 10)
.Select(i => new KpiSeriesPoint(T(i * 6), (double)i))
.ToArray();
// 60-minute window / 2 buckets → 30 min each.
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
// Bucket 0: T(0)T(29) → last is T(24) (value 4); bucket 1: T(30)T(60) → last is T(54) (value 9).
Assert.Equal(4.0, result[0].Value);
Assert.Equal(9.0, result[1].Value);
}
}