Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

489 lines
20 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
// -----------------------------------------------------------------------
/// <summary>Verifies that a maximum point count below two throws an ArgumentOutOfRangeException.</summary>
[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));
}
/// <summary>Verifies that a maximum point count of zero throws an ArgumentOutOfRangeException.</summary>
[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));
}
/// <summary>Verifies that a window whose end equals its start throws an ArgumentOutOfRangeException.</summary>
[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));
}
/// <summary>Verifies that a window that ends before it starts throws an ArgumentOutOfRangeException.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a null raw series returns an empty list.</summary>
[Fact]
public void Bucket_NullRaw_ReturnsEmptyList()
{
var result = KpiSeriesBucketer.Bucket(null!, T(0), T(60), maxPoints: 10);
Assert.Empty(result);
}
/// <summary>Verifies that an empty raw series returns an empty list.</summary>
[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)
// -----------------------------------------------------------------------
/// <summary>Verifies that a raw series with exactly the maximum number of points is returned as the same reference.</summary>
[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);
}
/// <summary>Verifies that a raw series with fewer points than the maximum is returned as the same reference.</summary>
[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);
}
/// <summary>Verifies that a single-point series is returned as the same reference.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a series with more points than the maximum is reduced to the maximum number of buckets.</summary>
[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);
}
/// <summary>Verifies that when several points fall in the same bucket the last value wins.</summary>
[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
}
/// <summary>Verifies that each output point carries the bucket start rather than the raw point timestamp.</summary>
[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);
}
// -----------------------------------------------------------------------
// Unsorted input: last-in-iteration wins within a bucket (NOT largest timestamp)
// -----------------------------------------------------------------------
/// <summary>Verifies that for unsorted input the last point in iteration order is selected, not the point with the largest timestamp.</summary>
[Fact]
public void Bucket_UnsortedInput_SelectsLastInIterationNotLargestTimestamp()
{
// Regression: pins the documented contract that for unsorted input the
// bucketer selects the LAST point in iteration order within a bucket — it does NOT pick
// the largest-timestamp point. Both points below fall in bucket 0 ([0, 30)); the later
// one in iteration order, T(5)=value 1.0, arrives second, so it overwrites T(20)=value
// 99.0 even though T(20) has the larger timestamp. (For ascending-sorted input these
// coincide — last-in-iteration IS largest timestamp — which is why production is safe.)
var raw = new[]
{
new KpiSeriesPoint(T(20), 99.0), // larger timestamp, but encountered FIRST
new KpiSeriesPoint(T(5), 1.0), // smaller timestamp, but encountered LAST → wins
new KpiSeriesPoint(T(45), 7.0), // bucket 1 ([30, 60])
};
// 60-minute window / 2 buckets → 30 min each. raw.Count (3) > maxPoints (2) → downsample.
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
// Bucket 0: last-in-iteration (value 1.0) wins, NOT the largest-timestamp point (99.0).
Assert.Equal(1.0, result[0].Value);
Assert.Equal(7.0, result[1].Value);
}
// -----------------------------------------------------------------------
// Short series (raw.Count <= maxPoints): returned unchanged → raw capture timestamps
// -----------------------------------------------------------------------
/// <summary>Verifies that a short series keeps its raw capture timestamps instead of bucket boundaries.</summary>
[Fact]
public void Bucket_ShortSeries_ReturnsRawCaptureTimestampsNotBucketBoundaries()
{
// Regression: the short-series early-return path returns the input
// unchanged, so each output point's BucketStartUtc is the RAW capture timestamp — NOT a
// bucket-boundary timestamp (which is what the downsample path emits, asserted by
// Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp). This pins the
// intentional difference between the two return paths.
var raw = new[]
{
new KpiSeriesPoint(T(7), 1.0),
new KpiSeriesPoint(T(23), 2.0),
};
// raw.Count (2) <= maxPoints (5) → early return, same reference.
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5);
Assert.Same(raw, result);
// Timestamps are the raw capture instants, not bucket starts (which would be T(0), T(12), …).
Assert.Equal(T(7), result[0].BucketStartUtc);
Assert.Equal(T(23), result[1].BucketStartUtc);
}
// -----------------------------------------------------------------------
// Right-edge: point exactly at toUtc lands in the last bucket
// -----------------------------------------------------------------------
/// <summary>Verifies that a point exactly at the window end lands in the last bucket.</summary>
[Fact]
public void Bucket_PointAtToUtc_LandsInLastBucket()
{
// 60-minute window / 2 buckets → 30 min each.
// A point exactly at T(60) = toUtc must go to bucket 1 (the last bucket),
// not overflow to an out-of-range index.
// T(10) → bucket 0: [0, 30); T(35) → bucket 1: [30, 60]; T(60) → bucket 1.
var raw = new[]
{
new KpiSeriesPoint(T(10), 5.0),
new KpiSeriesPoint(T(35), 6.0),
new KpiSeriesPoint(T(60), 7.0), // exactly toUtc → must land in last bucket
};
var result = KpiSeriesBucketer.Bucket(raw, 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);
}
/// <summary>Verifies that in a three-bucket window a point exactly at the window end lands in the last bucket.</summary>
[Fact]
public void Bucket_ThreeBucketWindow_PointAtToUtc_LandsInLastBucket()
{
// 60-minute window / 3 buckets → 20 min each.
// A point exactly at T(60) = toUtc must land in bucket 2 (the last),
// not overflow. Use 4 raw points so downsampling is forced (4 > 3).
// T(5) → bucket 0: [0,20); T(25) → bucket 1: [20,40);
// T(45) → bucket 2: [40,60]; T(60) → bucket 2 (right edge, later → wins).
var raw = new[]
{
new KpiSeriesPoint(T(5), 1.0),
new KpiSeriesPoint(T(25), 2.0),
new KpiSeriesPoint(T(45), 3.0),
new KpiSeriesPoint(T(60), 99.0), // right edge — must land in last bucket
};
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);
Assert.Equal(3, result.Count);
// Bucket 2 holds T(45)=3.0 and T(60)=99.0; T(60) is later → wins.
Assert.Equal(99.0, result[2].Value);
}
// -----------------------------------------------------------------------
// Empty buckets omitted — no gap-filling
// -----------------------------------------------------------------------
/// <summary>Verifies that empty buckets caused by a gap in the raw data are omitted.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that points outside the window are ignored.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a maximum of exactly two points produces at most two buckets.</summary>
[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);
}
// -----------------------------------------------------------------------
// Per-metric reduction: Rate series sum per bucket (arch-review 04 round 2, R3)
// -----------------------------------------------------------------------
/// <summary>Verifies that a rate series sums the values in each bucket instead of taking the last value.</summary>
[Fact]
public void Bucket_RateSeries_SumsPerBucket_InsteadOfLastValue()
{
// 6 points of value 10 across a 60-min / 2-bucket window → each bucket = 30 (sum),
// not 10 (last value). Three deltas per bucket; last-value would keep 1 of 3.
var raw = new[]
{
new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 30)
new KpiSeriesPoint(T(15), 10.0), // bucket 0
new KpiSeriesPoint(T(25), 10.0), // bucket 0
new KpiSeriesPoint(T(35), 10.0), // bucket 1: [30, 60]
new KpiSeriesPoint(T(45), 10.0), // bucket 1
new KpiSeriesPoint(T(55), 10.0), // bucket 1
};
var result = KpiSeriesBucketer.Bucket(
raw, T(0), T(60), maxPoints: 2, aggregation: KpiRollupAggregation.Rate);
Assert.Equal(2, result.Count);
Assert.Equal(30.0, result[0].Value);
Assert.Equal(30.0, result[1].Value);
}
/// <summary>Verifies that a short rate series still sums clustered points.</summary>
[Fact]
public void Bucket_RateSeries_ShortSeries_StillSumsClusteredPoints()
{
// 3 points (< maxPoints 5) with two sharing a bucket → the shared bucket is their SUM.
// Rate series skip the raw.Count <= maxPoints early return so totals stay truthful.
// 60-min / 5-bucket window → 12 min each. T(2),T(5) → bucket 0 [0,12); T(30) → bucket 2 [24,36).
var raw = new[]
{
new KpiSeriesPoint(T(2), 10.0),
new KpiSeriesPoint(T(5), 10.0),
new KpiSeriesPoint(T(30), 10.0),
};
var result = KpiSeriesBucketer.Bucket(
raw, T(0), T(60), maxPoints: 5, aggregation: KpiRollupAggregation.Rate);
Assert.Equal(2, result.Count);
Assert.Equal(20.0, result[0].Value); // T(2)+T(5) summed
Assert.Equal(10.0, result[1].Value); // T(30) alone
}
/// <summary>Verifies that bucketing a rate series preserves the series total.</summary>
[Fact]
public void Bucket_RateSeries_PreservesSeriesTotal()
{
// Strong invariant: sum(output values) == sum(in-window input values) for Rate.
var raw = Enumerable
.Range(0, 20)
.Select(i => new KpiSeriesPoint(T(i * 3), (double)(i + 1)))
.ToArray();
var result = KpiSeriesBucketer.Bucket(
raw, T(0), T(60), maxPoints: 4, aggregation: KpiRollupAggregation.Rate);
var inWindowTotal = raw
.Where(p => p.BucketStartUtc >= T(0) && p.BucketStartUtc <= T(60))
.Sum(p => p.Value);
Assert.Equal(inWindowTotal, result.Sum(p => p.Value));
}
/// <summary>Verifies that the default aggregation is gauge and its behavior is unchanged.</summary>
[Fact]
public void Bucket_DefaultAggregation_IsGauge_AndBehaviorUnchanged()
{
// Calling the existing 4-arg shape produces identical output to the explicit Gauge call.
var raw = new[]
{
new KpiSeriesPoint(T(5), 1.0),
new KpiSeriesPoint(T(15), 2.0),
new KpiSeriesPoint(T(25), 99.0),
new KpiSeriesPoint(T(35), 5.0),
};
var fourArg = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
var explicitGauge = KpiSeriesBucketer.Bucket(
raw, T(0), T(60), maxPoints: 2, aggregation: KpiRollupAggregation.Gauge);
Assert.Equal(fourArg.Count, explicitGauge.Count);
for (int i = 0; i < fourArg.Count; i++)
{
Assert.Equal(fourArg[i].BucketStartUtc, explicitGauge[i].BucketStartUtc);
Assert.Equal(fourArg[i].Value, explicitGauge[i].Value);
}
// And the last-value semantics are unchanged.
Assert.Equal(99.0, fourArg[0].Value);
Assert.Equal(5.0, fourArg[1].Value);
}
}