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)); } }