using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
///
/// 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.
///
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 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(() => 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));
}
}