ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
504 lines
21 KiB
C#
504 lines
21 KiB
C#
using MeterVault.Core.Analysis;
|
||
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
|
||
|
||
namespace MeterVault.Core.Tests.Analysis;
|
||
|
||
/// <summary>
|
||
/// Buckets are local calendar units clipped to the period (D-05). What these pin down: "last 12 months" is
|
||
/// exactly 12 buckets ending at now; weeks start on Monday; DST days are 23 or 25 hours; Auto picks one
|
||
/// sensible size that never undercuts the data's resolution; an explicit size over 400 points is refused
|
||
/// with a coarser suggestion rather than truncated.
|
||
/// </summary>
|
||
public sealed class BucketPlannerTests
|
||
{
|
||
private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37);
|
||
|
||
private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
|
||
PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin);
|
||
|
||
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
|
||
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, zone ?? Berlin);
|
||
|
||
private static void AssertTiles(ResolvedPeriod period, IReadOnlyList<AnalysisBucket> buckets)
|
||
{
|
||
Assert.NotEmpty(buckets);
|
||
Assert.Equal(period.From, buckets[0].From);
|
||
Assert.Equal(period.To, buckets[^1].To);
|
||
Assert.Equal(period.FirstDay, buckets[0].FirstDay);
|
||
for (var i = 1; i < buckets.Count; i++)
|
||
{
|
||
Assert.Equal(buckets[i - 1].To, buckets[i].From);
|
||
Assert.Equal(buckets[i - 1].EndDay, buckets[i].FirstDay);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void The_last_12_months_give_exactly_12_month_buckets_the_last_one_ending_now()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(12, plan.Buckets.Count);
|
||
Assert.Equal(12, plan.PointCount);
|
||
AssertTiles(period, plan.Buckets);
|
||
Assert.Equal(
|
||
["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"],
|
||
plan.Buckets.Select(b => $"{b.FirstDay.Year:D4}-{b.FirstDay.Month:D2}"));
|
||
|
||
var october = plan.Buckets[0];
|
||
Assert.Equal((Day(2025, 10, 1), Day(2025, 11, 1)), (october.FirstDay, october.EndDay));
|
||
Assert.Equal((Utc(2025, 9, 30, 22), Utc(2025, 10, 31, 23)), (october.From, october.To));
|
||
|
||
var september = plan.Buckets[^1];
|
||
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (september.FirstDay, september.EndDay));
|
||
Assert.Equal(September19, september.To);
|
||
Assert.All(plan.Buckets, b => Assert.Equal(BucketSize.Month, b.Size));
|
||
}
|
||
|
||
[Fact]
|
||
public void Across_New_Year_the_last_12_months_still_give_12_buckets_ending_with_the_new_January()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2027, 1, 1, 0, 30));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.Equal(12, plan.Buckets.Count);
|
||
Assert.Equal(Day(2026, 2, 1), plan.Buckets[0].FirstDay);
|
||
Assert.Equal(Day(2027, 1, 1), plan.Buckets[^1].FirstDay);
|
||
Assert.Equal(TimeSpan.FromMinutes(30), plan.Buckets[^1].To - plan.Buckets[^1].From);
|
||
}
|
||
|
||
[Fact]
|
||
public void At_the_exact_midnight_that_starts_a_month_the_last_12_months_keep_12_buckets_the_new_one_empty()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.Equal(12, plan.Buckets.Count);
|
||
Assert.Equal(Day(2026, 10, 1), plan.Buckets[^1].FirstDay);
|
||
Assert.Equal(plan.Buckets[^1].From, plan.Buckets[^1].To);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(BucketSize.Day)]
|
||
[InlineData(BucketSize.Week)]
|
||
[InlineData(BucketSize.Month)]
|
||
[InlineData(BucketSize.Auto)]
|
||
public void At_the_exact_midnight_that_starts_a_month_month_to_date_has_one_empty_bucket_like_the_last_12_months(BucketSize size)
|
||
{
|
||
var now = At(Berlin, 2026, 10, 1);
|
||
var monthToDate = Preset(PeriodPreset.MonthToDate, now);
|
||
var last12 = Preset(PeriodPreset.Last12Months, now);
|
||
|
||
var plan = BucketPlanner.Plan(monthToDate, size);
|
||
|
||
// Both presets agree that October exists and is empty: no "not yet occurred" for one and an empty
|
||
// bucket for the other.
|
||
var today = Assert.Single(plan.Buckets);
|
||
Assert.Equal((Day(2026, 10, 1), now, now), (today.FirstDay, today.From, today.To));
|
||
Assert.Equal(1, plan.PointCount);
|
||
Assert.Equal(last12.To, BucketPlanner.Plan(last12, BucketSize.Month).Buckets[^1].To);
|
||
AssertTiles(monthToDate, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Week_buckets_start_on_Monday_and_the_first_one_starts_with_the_period()
|
||
{
|
||
// 1 September 2026 is a Tuesday.
|
||
var period = Preset(PeriodPreset.MonthToDate);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Week);
|
||
|
||
Assert.Equal(
|
||
[(Day(2026, 9, 1), Day(2026, 9, 7)), (Day(2026, 9, 7), Day(2026, 9, 14)), (Day(2026, 9, 14), Day(2026, 9, 20))],
|
||
plan.Buckets.Select(b => (b.FirstDay, b.EndDay)));
|
||
Assert.Equal(DayOfWeek.Tuesday, plan.Buckets[0].FirstDay.DayOfWeek);
|
||
Assert.All(plan.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, b.FirstDay.DayOfWeek));
|
||
Assert.Equal(September19, plan.Buckets[^1].To);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_range_from_Monday_to_Sunday_is_whole_weeks()
|
||
{
|
||
var period = Custom(Day(2026, 9, 7), Day(2026, 9, 20), At(Berlin, 2026, 10, 1, 12, 0));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Week);
|
||
|
||
Assert.Equal(2, plan.Buckets.Count);
|
||
Assert.All(plan.Buckets, b => Assert.Equal(7, b.EndDay.DayNumber - b.FirstDay.DayNumber));
|
||
Assert.Equal(Utc(2026, 9, 20, 22), plan.Buckets[^1].To);
|
||
}
|
||
|
||
[Fact]
|
||
public void Day_buckets_follow_local_midnight_so_the_spring_DST_day_has_23_hours()
|
||
{
|
||
var march = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 2, 9, 0));
|
||
|
||
var plan = BucketPlanner.Plan(march, BucketSize.Day);
|
||
|
||
Assert.Equal(31, plan.Buckets.Count);
|
||
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 3, 29));
|
||
Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 22)), (dstDay.From, dstDay.To));
|
||
Assert.Equal(TimeSpan.FromHours(23), dstDay.To - dstDay.From);
|
||
Assert.All(plan.Buckets.Where(b => b != dstDay), b => Assert.Equal(TimeSpan.FromHours(24), b.To - b.From));
|
||
AssertTiles(march, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Day_buckets_follow_local_midnight_so_the_autumn_DST_day_has_25_hours()
|
||
{
|
||
var october = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0));
|
||
|
||
var plan = BucketPlanner.Plan(october, BucketSize.Day);
|
||
|
||
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 10, 25));
|
||
Assert.Equal((Utc(2026, 10, 24, 22), Utc(2026, 10, 25, 23)), (dstDay.From, dstDay.To));
|
||
Assert.Equal(TimeSpan.FromHours(25), dstDay.To - dstDay.From);
|
||
}
|
||
|
||
[Fact]
|
||
public void Behind_UTC_month_buckets_start_at_New_York_midnight()
|
||
{
|
||
var period = Custom(Day(2026, 10, 1), Day(2026, 11, 30), zone: NewYork, now: At(NewYork, 2026, 12, 15, 12, 0));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.Equal(
|
||
[(Utc(2026, 10, 1, 4), Utc(2026, 11, 1, 4)), (Utc(2026, 11, 1, 4), Utc(2026, 12, 1, 5))],
|
||
plan.Buckets.Select(b => (b.From, b.To)));
|
||
}
|
||
|
||
[Fact]
|
||
public void A_custom_range_past_now_gets_buckets_only_up_to_now()
|
||
{
|
||
var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
var only = Assert.Single(plan.Buckets);
|
||
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (only.FirstDay, only.EndDay));
|
||
Assert.Equal(September19, only.To);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_period_that_has_not_started_or_has_no_history_has_no_buckets()
|
||
{
|
||
var future = Custom(Day(2027, 1, 1), Day(2027, 3, 31));
|
||
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin);
|
||
|
||
foreach (var period in new[] { future, none })
|
||
{
|
||
foreach (var size in new[] { BucketSize.Auto, BucketSize.Day, BucketSize.Year })
|
||
{
|
||
var plan = BucketPlanner.Plan(period, size);
|
||
Assert.Empty(plan.Buckets);
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(0, plan.PointCount);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(PeriodPreset.MonthToDate, BucketSize.Day, 19)]
|
||
[InlineData(PeriodPreset.LastMonth, BucketSize.Day, 31)]
|
||
[InlineData(PeriodPreset.YearToDate, BucketSize.Month, 9)]
|
||
[InlineData(PeriodPreset.Last12Months, BucketSize.Month, 12)]
|
||
[InlineData(PeriodPreset.Last24Months, BucketSize.Month, 24)]
|
||
[InlineData(PeriodPreset.PreviousYear, BucketSize.Month, 12)]
|
||
public void Auto_picks_days_for_short_ranges_and_months_for_a_year_or_more(PeriodPreset preset, BucketSize expected, int points)
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Auto, plan.Requested);
|
||
Assert.Equal(expected, plan.Size);
|
||
Assert.Equal(points, plan.Buckets.Count);
|
||
Assert.False(plan.Refused);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(62, BucketSize.Day)]
|
||
[InlineData(63, BucketSize.Week)]
|
||
[InlineData(182, BucketSize.Week)]
|
||
[InlineData(183, BucketSize.Month)]
|
||
public void Auto_switches_from_days_to_weeks_after_62_days_and_to_months_after_26_weeks(int days, BucketSize expected)
|
||
{
|
||
var first = Day(2025, 1, 1);
|
||
var period = Custom(first, first.AddDays(days - 1));
|
||
|
||
Assert.Equal(expected, BucketPlanner.Plan(period, BucketSize.Auto).Size);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(1, 1)]
|
||
[InlineData(2, 15)]
|
||
[InlineData(3, 2)]
|
||
[InlineData(6, 15)]
|
||
[InlineData(7, 2)]
|
||
[InlineData(9, 19)]
|
||
[InlineData(12, 31)]
|
||
public void Auto_charts_year_to_date_by_month_on_every_day_of_the_year(int month, int day)
|
||
{
|
||
// Chosen from the named year, not the elapsed part (A-06): the same URL used to render by day until
|
||
// 2 March and by week until 1 July.
|
||
var period = Preset(PeriodPreset.YearToDate, At(Berlin, 2026, month, day, 12, 0));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Month, plan.Size);
|
||
Assert.Equal(month, plan.Buckets.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_charts_month_to_date_by_day_even_on_the_1st()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 9, 1, 8, 0)), BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Day, plan.Size);
|
||
Assert.Single(plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_sizes_a_custom_range_reaching_past_now_by_the_range_asked_for()
|
||
{
|
||
var first = Day(2026, 9, 1);
|
||
var last = Day(2026, 12, 31);
|
||
|
||
var inSeptember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 9, 19, 12, 0)), BucketSize.Auto);
|
||
var inNovember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 11, 20, 12, 0)), BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Week, inSeptember.Size);
|
||
Assert.Equal(BucketSize.Week, inNovember.Size);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_checks_the_point_limit_on_the_buckets_that_exist_up_to_now()
|
||
{
|
||
// The named year has 12 months, over a limit of 9; only the 9 that exist by 19 September are counted.
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.YearToDate), BucketSize.Auto, maxPoints: 9);
|
||
|
||
Assert.Equal(BucketSize.Month, plan.Size);
|
||
Assert.Equal(9, plan.PointCount);
|
||
Assert.False(plan.Refused);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_weighs_months_against_the_point_limit_it_was_given()
|
||
{
|
||
// 501 months since 1985: over the default 400, within a limit of 1,000.
|
||
var since1985 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1985, 1, 1));
|
||
|
||
var generous = BucketPlanner.Plan(since1985, BucketSize.Auto, maxPoints: 1_000);
|
||
var standard = BucketPlanner.Plan(since1985, BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Month, generous.Size);
|
||
Assert.Equal(501, generous.Buckets.Count);
|
||
Assert.Equal(BucketSize.Year, standard.Size);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_charts_all_history_from_a_stray_ancient_reading_by_year_instead_of_refusing()
|
||
{
|
||
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(206, 5, 1));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
|
||
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(BucketSize.Year, plan.Size);
|
||
Assert.Equal(2026 - 1900 + 1, plan.Buckets.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_uses_months_up_to_400_of_them_and_years_beyond()
|
||
{
|
||
// The reference data's oil history starts in 1997: 357 months to September 2026.
|
||
var since1997 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1));
|
||
var since1900 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1900, 1, 1));
|
||
|
||
var monthly = BucketPlanner.Plan(since1997, BucketSize.Auto);
|
||
var yearly = BucketPlanner.Plan(since1900, BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Month, monthly.Size);
|
||
Assert.Equal(357, monthly.Buckets.Count);
|
||
Assert.Equal(BucketSize.Year, yearly.Size);
|
||
Assert.Equal(127, yearly.Buckets.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_never_goes_finer_than_the_coarsest_resolution_a_series_needs()
|
||
{
|
||
var monthToDate = Preset(PeriodPreset.MonthToDate);
|
||
|
||
// A monthly import in a daily chart would be nothing but unresolved buckets.
|
||
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
|
||
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
|
||
|
||
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
|
||
|
||
var month = Assert.Single(monthly.Buckets);
|
||
Assert.Equal((Utc(2026, 8, 31, 22), September19), (month.From, month.To));
|
||
Assert.Equal(BucketSize.Week, weekly.Size);
|
||
Assert.Equal(BucketSize.Year, coarse.Size);
|
||
Assert.Equal([Day(2025, 10, 1), Day(2026, 1, 1)], coarse.Buckets.Select(b => b.FirstDay));
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_keeps_the_length_based_default_when_the_data_is_finer()
|
||
{
|
||
// Hourly data does not turn a year into 365 bars; the user can still ask for days explicitly.
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Hour);
|
||
|
||
Assert.Equal(BucketSize.Month, plan.Size);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_coarsens_until_the_plan_fits_a_smaller_point_limit()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, maxPoints: 10);
|
||
|
||
Assert.Equal(BucketSize.Week, plan.Size);
|
||
Assert.Equal(3, plan.Buckets.Count);
|
||
Assert.False(plan.Refused);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_explicit_day_bucket_over_400_points_is_refused_with_weeks_suggested()
|
||
{
|
||
var period = Preset(PeriodPreset.Last24Months);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Day);
|
||
|
||
Assert.True(plan.Refused);
|
||
Assert.Empty(plan.Buckets);
|
||
Assert.Equal(BucketSize.Day, plan.Size);
|
||
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2024, 10, 1).DayNumber + 1, plan.PointCount);
|
||
Assert.Equal(BucketSize.Week, plan.Suggested);
|
||
Assert.False(BucketPlanner.Plan(period, BucketSize.Week).Refused);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_refusal_suggests_nothing_finer_than_the_data_resolves()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last24Months), BucketSize.Day, ResolutionClass.Month);
|
||
|
||
Assert.True(plan.Refused);
|
||
Assert.Equal(BucketSize.Month, plan.Suggested);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_refusal_over_centuries_suggests_years()
|
||
{
|
||
var period = Custom(Day(1950, 1, 1), Day(2025, 12, 31));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.True(plan.Refused);
|
||
Assert.Equal(76 * 12, plan.PointCount);
|
||
Assert.Equal(BucketSize.Year, plan.Suggested);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_explicit_size_within_the_limit_is_honoured_even_finer_than_the_data()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Day, ResolutionClass.Month);
|
||
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2025, 10, 1).DayNumber + 1, plan.Buckets.Count);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Every_size_counts_its_buckets_without_building_them()
|
||
{
|
||
var periods = new[]
|
||
{
|
||
Preset(PeriodPreset.MonthToDate),
|
||
Preset(PeriodPreset.Last24Months),
|
||
Preset(PeriodPreset.PreviousYear),
|
||
Custom(Day(2023, 12, 31), Day(2025, 1, 1)),
|
||
Preset(PeriodPreset.YearToDate, At(NewYork, 2026, 3, 8, 12, 0), NewYork),
|
||
};
|
||
|
||
foreach (var period in periods)
|
||
{
|
||
foreach (var size in new[] { BucketSize.Day, BucketSize.Week, BucketSize.Month, BucketSize.Year })
|
||
{
|
||
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
|
||
Assert.Equal(BucketPlanner.CountBuckets(period, size), plan.Buckets.Count);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void The_current_month_bucket_is_cut_short_at_now_and_names_the_whole_month_to_drill_into()
|
||
{
|
||
// Drilling into "1 – 19 Sep" as a custom range would compare with 13 – 31 August; the whole month
|
||
// compares with 1 – 19 August, like month to date (D-51).
|
||
foreach (var preset in new[] { PeriodPreset.MonthToDate, PeriodPreset.YearToDate, PeriodPreset.Last12Months })
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Month);
|
||
|
||
var september = plan.Buckets[^1];
|
||
Assert.Equal((Day(2026, 9, 20), Day(2026, 10, 1)), (september.EndDay, september.NominalEndDay));
|
||
Assert.True(september.IsCutShort);
|
||
Assert.All(plan.Buckets.SkipLast(1), b => Assert.False(b.IsCutShort));
|
||
Assert.All(plan.Buckets.SkipLast(1), b => Assert.Null(b.NominalEndDay));
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void A_week_cut_at_now_names_its_Sunday_and_a_day_is_never_cut_short()
|
||
{
|
||
var weeks = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Week);
|
||
var days = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day);
|
||
|
||
Assert.Equal(Day(2026, 9, 21), weeks.Buckets[^1].NominalEndDay);
|
||
Assert.All(days.Buckets, b => Assert.False(b.IsCutShort));
|
||
}
|
||
|
||
[Fact]
|
||
public void The_whole_unit_of_a_cut_bucket_never_reaches_past_the_range_that_was_asked_for()
|
||
{
|
||
var reachingPastNow = BucketPlanner.Plan(Custom(Day(2026, 9, 1), Day(2026, 9, 25)), BucketSize.Month);
|
||
var complete = BucketPlanner.Plan(Custom(Day(2026, 8, 10), Day(2026, 9, 15), At(Berlin, 2026, 10, 1, 12, 0)), BucketSize.Month);
|
||
|
||
Assert.Equal(Day(2026, 9, 26), Assert.Single(reachingPastNow.Buckets).NominalEndDay);
|
||
|
||
// A range that ends mid-month by request is not cut short: its last bucket is all it names.
|
||
Assert.All(complete.Buckets, b => Assert.False(b.IsCutShort));
|
||
Assert.Equal(Day(2026, 9, 16), complete.Buckets[^1].EndDay);
|
||
}
|
||
|
||
[Fact]
|
||
public void At_midnight_the_empty_current_month_still_names_the_whole_month()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1)), BucketSize.Month);
|
||
|
||
Assert.Equal((Day(2026, 10, 2), Day(2026, 11, 1)), (plan.Buckets[^1].EndDay, plan.Buckets[^1].NominalEndDay));
|
||
}
|
||
|
||
[Fact]
|
||
public void The_point_limit_must_allow_at_least_one_point()
|
||
{
|
||
Assert.Throws<ArgumentOutOfRangeException>(() => BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day, maxPoints: 0));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(ResolutionClass.Hour, BucketSize.Day)]
|
||
[InlineData(ResolutionClass.Day, BucketSize.Day)]
|
||
[InlineData(ResolutionClass.Week, BucketSize.Week)]
|
||
[InlineData(ResolutionClass.Month, BucketSize.Month)]
|
||
[InlineData(ResolutionClass.Coarse, BucketSize.Year)]
|
||
public void Each_resolution_class_maps_to_the_finest_bucket_it_can_fill(ResolutionClass resolution, BucketSize expected)
|
||
{
|
||
Assert.Equal(expected, BucketPlanner.MinimumSizeFor(resolution));
|
||
}
|
||
}
|