using MeterVault.Core.Analysis; using static MeterVault.Core.Tests.Analysis.AnalysisClock; namespace MeterVault.Core.Tests.Analysis; /// /// Comparisons shift by local calendar units and map the to-date cut-off as a local date plus wall-clock /// time (D-06). What these pin down: a month to date compares with the same elapsed part of the previous /// month, 31 March with all of February, 29 February with all of the previous February; a cut-off in a DST /// gap takes the first instant after it and one in a fold takes the occurrence with now's offset. The same /// public mapping shifts any instant (D-07) and pairs the current buckets with the comparison's, and it never /// runs backwards. /// public sealed class ComparisonResolverTests { private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37); private static readonly ComparisonRequest PreviousPeriod = new(ComparisonKind.PreviousPeriod); private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear); 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) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, Berlin); private static ComparisonPeriod Applicable(ResolvedPeriod current, ComparisonRequest request) { var resolution = ComparisonResolver.Resolve(current, request); Assert.True(resolution.IsApplicable, $"not applicable: {resolution.Reason}"); Assert.Equal(ComparisonUnavailableReason.None, resolution.Reason); return resolution.Period; } private static ComparisonUnavailableReason Reason(ResolvedPeriod current, ComparisonRequest request) { var resolution = ComparisonResolver.Resolve(current, request); Assert.False(resolution.IsApplicable); Assert.Null(resolution.Period); return resolution.Reason; } [Fact] public void Month_to_date_compares_with_the_same_elapsed_part_of_the_previous_month() { var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod); Assert.Equal(Day(2026, 8, 1), comparison.FirstDay); Assert.Equal(Day(2026, 8, 19), comparison.LastDay); Assert.Equal(Day(2026, 8, 31), comparison.NominalLastDay); Assert.Equal(Utc(2026, 7, 31, 22), comparison.From); Assert.Equal(Utc(2026, 8, 19, 12, 37), comparison.To); Assert.True(comparison.IsCutOff); Assert.False(comparison.CappedAtNow); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 1), comparison.Shift); Assert.Equal(ComparisonKind.PreviousPeriod, comparison.Kind); } [Fact] public void Month_to_date_against_the_previous_year_is_the_same_month_to_the_same_wall_clock_time() { var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousYear); Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 19)), (comparison.FirstDay, comparison.LastDay)); Assert.Equal((Utc(2025, 8, 31, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To)); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 1), comparison.Shift); } [Fact] public void Year_to_date_compares_with_the_previous_year_up_to_the_same_moment() { var current = Preset(PeriodPreset.YearToDate); var previousPeriod = Applicable(current, PreviousPeriod); var previousYear = Applicable(current, PreviousYear); var named = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2023)); Assert.Equal((Utc(2024, 12, 31, 23), Utc(2025, 9, 19, 12, 37)), (previousPeriod.From, previousPeriod.To)); Assert.Equal(Day(2025, 12, 31), previousPeriod.NominalLastDay); Assert.Equal(previousPeriod, previousYear with { Kind = ComparisonKind.PreviousPeriod }); Assert.Equal((Utc(2022, 12, 31, 23), Utc(2023, 9, 19, 12, 37)), (named.From, named.To)); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 3), named.Shift); } [Fact] public void The_last_12_months_compare_with_the_12_months_before() { var comparison = Applicable(Preset(PeriodPreset.Last12Months), PreviousPeriod); Assert.Equal(Day(2024, 10, 1), comparison.FirstDay); Assert.Equal(Day(2025, 9, 30), comparison.NominalLastDay); Assert.Equal((Utc(2024, 9, 30, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To)); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 12), comparison.Shift); } [Fact] public void The_last_24_months_compare_with_the_24_months_before_or_shift_one_year_for_the_previous_year() { var current = Preset(PeriodPreset.Last24Months); var previousPeriod = Applicable(current, PreviousPeriod); var previousYear = Applicable(current, PreviousYear); Assert.Equal(Day(2022, 10, 1), previousPeriod.FirstDay); Assert.Equal(Utc(2024, 9, 19, 12, 37), previousPeriod.To); Assert.Equal(Day(2023, 10, 1), previousYear.FirstDay); Assert.Equal(Utc(2025, 9, 19, 12, 37), previousYear.To); } [Fact] public void A_complete_month_compares_with_the_complete_previous_month() { var august = Preset(PeriodPreset.LastMonth); var july = Applicable(august, PreviousPeriod); var augustLastYear = Applicable(august, PreviousYear); Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay)); Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To)); Assert.False(july.IsCutOff); Assert.Equal((Utc(2025, 7, 31, 22), Utc(2025, 8, 31, 22)), (augustLastYear.From, augustLastYear.To)); } [Fact] public void A_whole_month_compares_with_the_whole_previous_month_however_long_each_is() { // February (28 days) against January (31 days), not against the 28 days before 1 February. var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0)); var january = Applicable(february, PreviousPeriod); Assert.Equal((Day(2026, 1, 1), Day(2026, 1, 31)), (january.FirstDay, january.LastDay)); Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 1, 31, 23)), (january.From, january.To)); } [Fact] public void On_31_March_the_previous_period_is_all_of_February_because_February_has_no_31st() { var now = At(Berlin, 2026, 3, 31, 14, 37); var february = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); Assert.Equal((Day(2026, 2, 1), Day(2026, 2, 28)), (february.FirstDay, february.LastDay)); Assert.Equal((Utc(2026, 1, 31, 23), Utc(2026, 2, 28, 23)), (february.From, february.To)); Assert.False(february.IsCutOff); } [Theory] [InlineData(29)] [InlineData(30)] public void Late_March_days_February_lacks_also_compare_with_all_of_February(int day) { var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, day, 14, 37)), PreviousPeriod); Assert.Equal(Utc(2026, 2, 28, 23), february.To); } [Fact] public void On_28_March_the_previous_period_stops_on_28_February_at_the_same_wall_clock_time() { // March 28 is still winter time and so is February: 14:37 CET both times. var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 28, 14, 37)), PreviousPeriod); Assert.Equal(Utc(2026, 2, 28, 13, 37), february.To); Assert.Equal(Day(2026, 2, 28), february.LastDay); Assert.True(february.IsCutOff); } [Fact] public void On_29_February_2028_the_previous_year_is_all_of_February_2027() { var now = At(Berlin, 2028, 2, 29, 10, 0); var month = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); var year = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousYear); Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (month.FirstDay, month.LastDay)); Assert.Equal((Utc(2027, 1, 31, 23), Utc(2027, 2, 28, 23)), (month.From, month.To)); Assert.False(month.IsCutOff); Assert.Equal((Day(2027, 1, 1), Day(2027, 2, 28)), (year.FirstDay, year.LastDay)); Assert.Equal((Utc(2026, 12, 31, 23), Utc(2027, 2, 28, 23)), (year.From, year.To)); Assert.True(year.IsCutOff); } [Fact] public void A_complete_leap_February_compares_with_the_whole_shorter_February_a_year_earlier() { var february2028 = Preset(PeriodPreset.LastMonth, At(Berlin, 2028, 3, 5, 12, 0)); var february2027 = Applicable(february2028, PreviousYear); Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (february2027.FirstDay, february2027.NominalLastDay)); Assert.Equal(Utc(2027, 2, 28, 23), february2027.To); } [Fact] public void A_cut_off_that_falls_in_the_spring_DST_gap_takes_the_first_instant_after_the_gap() { // 29 March 2027 02:30 exists (2027 switches on 28 March); 29 March 2026 02:30 does not. var now = At(Berlin, 2027, 3, 29, 2, 30); var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); Assert.Equal(Utc(2026, 3, 29, 1), comparison.To); Assert.Equal("2026-03-29 03:00 +02:00", Wall(comparison.To, Berlin)); } [Fact] public void A_day_shifted_cut_off_into_the_spring_DST_gap_also_lands_on_the_end_of_the_gap() { var now = At(Berlin, 2026, 3, 30, 2, 30); var today = Custom(Day(2026, 3, 30), Day(2026, 3, 30), now); var yesterday = Applicable(today, PreviousPeriod); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 1), yesterday.Shift); Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 1)), (yesterday.From, yesterday.To)); } [Fact] public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_winter_offset() { // Now is 02:30 winter time on 26 October; 25 October 02:30 happened twice. var now = At(2026, 10, 26, 2, 30, offsetHours: 1); var today = Custom(Day(2026, 10, 26), Day(2026, 10, 26), now); var yesterday = Applicable(today, PreviousPeriod); Assert.Equal(Utc(2026, 10, 25, 1, 30), yesterday.To); Assert.Equal("2026-10-25 02:30 +01:00", Wall(yesterday.To, Berlin)); } [Fact] public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_summer_offset() { // 2027 leaves summer time on 31 October, so 25 October 2027 02:30 is summer time (+02:00). var now = At(Berlin, 2027, 10, 25, 2, 30); var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); Assert.Equal(Utc(2026, 10, 25, 0, 30), comparison.To); Assert.Equal("2026-10-25 02:30 +02:00", Wall(comparison.To, Berlin)); } [Fact] public void An_ambiguous_wall_time_whose_offsets_do_not_include_nows_takes_the_first_occurrence() { var wall = new DateTime(2026, 10, 25, 2, 30, 0, DateTimeKind.Unspecified); var instant = LocalCalendar.InstantOf(wall, Berlin, TimeSpan.FromHours(5)); Assert.Equal(Utc(2026, 10, 25, 0, 30), instant); } [Fact] public void A_now_in_the_first_pass_of_the_repeated_hour_maps_into_an_ordinary_month_at_the_same_wall_clock_time() { var now = At(2026, 10, 25, 2, 30, offsetHours: 2); var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); Assert.Equal("2026-09-25 02:30 +02:00", Wall(september.To, Berlin)); } [Fact] public void A_now_in_the_second_pass_of_the_repeated_hour_maps_to_the_end_of_that_hour_in_an_ordinary_month() { // By 02:30 winter time the whole first pass (02:00 – 03:00 summer time) has elapsed. Mapping it to // 02:30 again would compare 3.5 elapsed hours of the day with 2.5, and put the cut before the image // of 02:45 summer time — a mapping that runs backwards, which matched coverage cannot invert. var now = At(2026, 10, 25, 2, 30, offsetHours: 1); var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); Assert.Equal("2026-09-25 03:00 +02:00", Wall(september.To, Berlin)); } [Fact] public void A_month_shifted_cut_off_into_the_repeated_hour_takes_the_occurrence_with_nows_winter_offset() { // 25 November is winter time (+01:00); 25 October 02:30 happened twice. The first occurrence would be // 00:30 UTC, so only now's offset explains 01:30 UTC. var now = At(Berlin, 2026, 11, 25, 2, 30); var october = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); Assert.Equal(Utc(2026, 10, 25, 1, 30), october.To); Assert.Equal("2026-10-25 02:30 +01:00", Wall(october.To, Berlin)); } [Theory] [InlineData(2, 0)] [InlineData(1, 1)] public void A_cut_off_in_a_repeated_hour_against_a_year_that_repeats_the_same_hour_keeps_its_pass(int offsetHours, int utcHour) { // 31 October is the autumn change both in 2027 and in 2021: first pass maps to first, second to second. var now = At(2027, 10, 31, 2, 30, offsetHours); var year2021 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2021)); Assert.Equal(Utc(2021, 10, 31, utcHour, 30), year2021.To); } [Fact] public void In_New_York_a_cut_off_in_the_spring_gap_takes_the_first_instant_after_it() { // 8 March 2026 skips 02:00 – 03:00; 8 April 02:30 exists. var now = At(NewYork, 2026, 4, 8, 2, 30); var march = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod); Assert.Equal(Utc(2026, 3, 8, 7), march.To); Assert.Equal("2026-03-08 03:00 -04:00", Wall(march.To, NewYork)); } [Fact] public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_standard_offset() { // 1 November 2026 repeats 01:00 – 02:00; 1 December 01:30 is EST (-05:00). The first occurrence // would be 05:30 UTC. var now = At(NewYork, 2026, 12, 1, 1, 30); var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod); Assert.Equal(Utc(2026, 11, 1, 6, 30), november.To); Assert.Equal(Utc(2026, 11, 1, 4), november.From); } [Fact] public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_daylight_offset() { // 2027 falls back on 7 November, so 1 November 2027 01:30 is EDT (-04:00). var now = At(NewYork, 2027, 11, 1, 1, 30); var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousYear); Assert.Equal(Utc(2026, 11, 1, 5, 30), november.To); } [Fact] public void Half_an_hour_into_New_Year_every_to_date_preset_compares_with_the_same_half_hour_before() { var now = At(Berlin, 2027, 1, 1, 0, 30); var december = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); var january2026 = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); var year2026 = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousPeriod); var year2020 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2020)); var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousPeriod); Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 11, 30, 23, 30)), (december.From, december.To)); Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 1), Day(2026, 12, 31)), (december.FirstDay, december.LastDay, december.NominalLastDay)); Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (january2026.From, january2026.To)); Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (year2026.From, year2026.To)); Assert.Equal(Day(2026, 12, 31), year2026.NominalLastDay); Assert.Equal((Utc(2019, 12, 31, 23), Utc(2019, 12, 31, 23, 30)), (year2020.From, year2020.To)); Assert.Equal((Day(2025, 2, 1), Day(2026, 1, 31)), (last12.FirstDay, last12.NominalLastDay)); Assert.Equal((Utc(2025, 1, 31, 23), Utc(2025, 12, 31, 23, 30)), (last12.From, last12.To)); Assert.All(new[] { december, january2026, year2026, year2020, last12 }, c => Assert.True(c.IsCutOff)); } [Fact] public void Half_an_hour_into_New_Year_last_month_is_December_and_compares_with_all_of_November() { var november = Applicable(Preset(PeriodPreset.LastMonth, At(Berlin, 2027, 1, 1, 0, 30)), PreviousPeriod); Assert.Equal((Utc(2026, 10, 31, 23), Utc(2026, 11, 30, 23)), (november.From, november.To)); Assert.False(november.IsCutOff); } [Fact] public void At_the_midnight_that_starts_a_month_month_to_date_compares_with_an_equally_empty_start_of_the_previous_month() { var now = At(Berlin, 2026, 10, 1); var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousYear); // Nothing has elapsed on either side; the comparison applies, as it does for the last 12 months. Assert.Equal((Utc(2026, 8, 31, 22), Utc(2026, 8, 31, 22)), (september.From, september.To)); Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 1), Day(2026, 9, 30)), (september.FirstDay, september.LastDay, september.NominalLastDay)); Assert.True(september.IsCutOff); Assert.Equal(Utc(2025, 9, 30, 22), last12.To); } [Fact] public void A_day_range_starting_on_29_February_compares_from_1_March_because_that_day_has_no_counterpart() { var now = At(Berlin, 2028, 6, 1, 12, 0); var comparison = Applicable(Custom(Day(2028, 2, 29), Day(2028, 3, 5), now), PreviousYear); Assert.Equal((Day(2027, 3, 1), Day(2027, 3, 5)), (comparison.FirstDay, comparison.LastDay)); Assert.Equal((Utc(2027, 2, 28, 23), Utc(2027, 3, 5, 23)), (comparison.From, comparison.To)); // 29 February alone has nothing to compare with. Assert.Equal(ComparisonUnavailableReason.Empty, Reason(Custom(Day(2028, 2, 29), Day(2028, 2, 29), now), PreviousYear)); } [Theory] [InlineData("2026-03-31", "2026-03-01")] [InlineData("2026-03-30", "2026-03-01")] [InlineData("2026-03-29", "2026-03-01")] [InlineData("2026-03-28", "2026-02-28")] [InlineData("2026-03-01", "2026-02-01")] [InlineData("2026-04-01", "2026-03-01")] public void Shifting_a_day_back_a_month_collapses_the_days_the_target_lacks_onto_its_end(string date, string expected) { Assert.Equal(Iso(expected), ComparisonResolver.ShiftDate(Iso(date), new ComparisonShift(ComparisonShiftUnit.Month, 1))); } [Fact] public void Shifting_29_February_back_a_year_lands_on_1_March_and_a_day_shift_is_exact() { Assert.Equal(Day(2027, 3, 1), ComparisonResolver.ShiftDate(Day(2028, 2, 29), new ComparisonShift(ComparisonShiftUnit.Year, 1))); Assert.Equal(Day(2028, 2, 28), ComparisonResolver.ShiftDate(Day(2029, 2, 28), new ComparisonShift(ComparisonShiftUnit.Year, 1))); Assert.Equal(Day(2027, 3, 31), ComparisonResolver.ShiftDate(Day(2026, 3, 31), new ComparisonShift(ComparisonShiftUnit.Year, -1))); Assert.Equal(Day(2026, 2, 21), ComparisonResolver.ShiftDate(Day(2026, 3, 3), new ComparisonShift(ComparisonShiftUnit.Day, 10))); } [Fact] public void A_covered_range_that_starts_on_a_day_the_target_month_lacks_starts_where_that_month_ends() { var shift = new ComparisonShift(ComparisonShiftUnit.Month, 1); var offset = TimeSpan.FromHours(2); var start = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30, 10, 0), shift, Berlin, offset); var sameDayEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 31, 14, 37), shift, Berlin, offset); var aprilEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 5, 12, 0), shift, Berlin, offset); // Coverage of 30 – 31 March has no counterpart in February: its image is empty… Assert.Equal(Utc(2026, 2, 28, 23), start); Assert.Equal(start, sameDayEnd); // …and coverage running on into April matches from 1 March, not from a clamped 28 February. Assert.Equal(Utc(2026, 3, 5, 11), aprilEnd); } [Fact] public void The_start_of_a_local_day_maps_to_the_start_of_the_target_day() { var month = new ComparisonShift(ComparisonShiftUnit.Month, 1); var year = new ComparisonShift(ComparisonShiftUnit.Year, 1); Assert.Equal(Utc(2026, 2, 28, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 1), month, Berlin, TimeSpan.FromHours(2))); Assert.Equal(Utc(2025, 3, 29, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30), year, Berlin, TimeSpan.FromHours(2))); Assert.Equal(Utc(2026, 10, 1, 4), ComparisonResolver.MapInstant(At(NewYork, 2026, 11, 1), month, NewYork, TimeSpan.FromHours(-5))); } [Fact] public void The_comparison_bounds_are_the_images_of_the_current_bounds_under_the_public_mapping() { var cases = new (ResolvedPeriod Current, ComparisonRequest Request)[] { (Preset(PeriodPreset.MonthToDate), PreviousPeriod), (Preset(PeriodPreset.MonthToDate), PreviousYear), (Preset(PeriodPreset.YearToDate), new ComparisonRequest(ComparisonKind.Year, 2023)), (Preset(PeriodPreset.Last12Months), PreviousPeriod), (Preset(PeriodPreset.LastMonth), PreviousPeriod), (Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod), (Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37)), PreviousPeriod), (Preset(PeriodPreset.MonthToDate, At(2026, 10, 25, 2, 30, offsetHours: 1)), PreviousPeriod), (Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0)), PreviousYear), (Preset(PeriodPreset.MonthToDate, At(NewYork, 2026, 12, 1, 1, 30), NewYork), PreviousPeriod), }; foreach (var (current, request) in cases) { var comparison = Applicable(current, request); Assert.Equal(comparison.From, comparison.MapInstant(current.From, current.Zone)); Assert.Equal(comparison.To, comparison.MapInstant(current.To, current.Zone)); } } public static TheoryData SweepZones => ["Europe/Berlin", "America/New_York", "America/Santiago", "Australia/Lord_Howe"]; [Theory] [MemberData(nameof(SweepZones))] public void The_instant_mapping_never_runs_backwards_across_DST_changes_and_short_months(string zoneId) { // Matched coverage (D-07) inverts the mapping by bisection, which needs it non-decreasing. A year of // quarter hours crosses both DST changes, both passes of the repeated hour, and every short month. var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId); ComparisonShift[] shifts = [ new(ComparisonShiftUnit.Day, 1), new(ComparisonShiftUnit.Day, 10), new(ComparisonShiftUnit.Month, 1), new(ComparisonShiftUnit.Month, 12), new(ComparisonShiftUnit.Year, 1), new(ComparisonShiftUnit.Year, -1), ]; TimeSpan[] offsets = [zone.GetUtcOffset(Utc(2026, 1, 15)), zone.GetUtcOffset(Utc(2026, 7, 15))]; foreach (var shift in shifts) { foreach (var offset in offsets) { var previousInstant = Utc(2026, 1, 1); var previous = ComparisonResolver.MapInstant(previousInstant, shift, zone, offset); for (var instant = previousInstant.AddMinutes(15); instant < Utc(2027, 1, 1); instant = instant.AddMinutes(15)) { var mapped = ComparisonResolver.MapInstant(instant, shift, zone, offset); if (mapped < previous) { Assert.Fail($"{shift} with offset {offset}: {Wall(instant, zone)} maps to {Wall(mapped, zone)}, before {Wall(previousInstant, zone)} → {Wall(previous, zone)}."); } previousInstant = instant; previous = mapped; } } } } [Fact] public void A_cut_comparison_reads_as_a_period_to_date_as_of_its_mapped_cut_off() { var current = Preset(PeriodPreset.MonthToDate); var comparison = Applicable(current, PreviousPeriod); var period = comparison.ToResolvedPeriod(current); Assert.Equal(PeriodPreset.Custom, period.Preset); Assert.Equal((Day(2026, 8, 1), Day(2026, 8, 31)), (period.FirstDay, period.LastDay)); Assert.Equal((comparison.From, comparison.To, comparison.To), (period.From, period.To, period.Now)); Assert.True(period.IsToDate); Assert.True(period.ExtendsPastNow); Assert.Same(Berlin, period.Zone); Assert.Equal(Day(2026, 8, 19), period.EffectiveLastDay()); Assert.Equal(Day(2026, 8, 31), period.NominalLastDay()); var days = BucketPlanner.Plan(period, BucketSize.Day); var month = Assert.Single(BucketPlanner.Plan(period, BucketSize.Month).Buckets); Assert.Equal(19, days.Buckets.Count); Assert.Equal(comparison.To, days.Buckets[^1].To); Assert.Equal((Day(2026, 8, 20), Day(2026, 9, 1)), (month.EndDay, month.NominalEndDay)); } [Fact] public void A_complete_comparison_reads_as_a_complete_period() { var current = Preset(PeriodPreset.LastMonth); var july = Applicable(current, PreviousPeriod).ToResolvedPeriod(current); Assert.False(july.IsToDate); Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay)); Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To)); Assert.Equal(current.Now, july.Now); Assert.Equal(31, BucketPlanner.Plan(july, BucketSize.Day).Buckets.Count); } [Fact] public void A_comparison_cut_at_a_midnight_by_a_missing_day_reads_as_complete_without_an_empty_extra_day() { var current = Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0)); var comparison = Applicable(current, PreviousYear); var period = comparison.ToResolvedPeriod(current); Assert.False(period.IsToDate); Assert.Equal((Day(2027, 2, 28), comparison.To), (period.LastDay, period.To)); Assert.Equal( [Day(2027, 1, 1), Day(2027, 2, 1)], BucketPlanner.Plan(period, BucketSize.Month).Buckets.Select(b => b.FirstDay)); } [Fact] public void At_midnight_the_comparison_keeps_the_same_empty_last_day_as_the_current_period() { var now = At(Berlin, 2026, 10, 1); var last12 = Preset(PeriodPreset.Last12Months, now); var monthToDate = Preset(PeriodPreset.MonthToDate, now); var yearBefore = Applicable(last12, PreviousYear).ToResolvedPeriod(last12); var monthBefore = Applicable(monthToDate, PreviousPeriod).ToResolvedPeriod(monthToDate); Assert.True(yearBefore.IsToDate); Assert.Equal(12, BucketPlanner.Plan(yearBefore, BucketSize.Month).Buckets.Count); var empty = Assert.Single(BucketPlanner.Plan(monthBefore, BucketSize.Day).Buckets); Assert.Equal((Day(2026, 9, 1), empty.From), (empty.FirstDay, empty.To)); } [Fact] public void A_named_later_year_still_running_reads_as_to_date_at_the_real_now() { var current = Preset(PeriodPreset.PreviousYear); var year2026 = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026)).ToResolvedPeriod(current); Assert.True(year2026.IsToDate); Assert.Equal((September19, September19), (year2026.To, year2026.Now)); Assert.Equal((Day(2026, 12, 31), Day(2026, 9, 19)), (year2026.LastDay, year2026.EffectiveLastDay())); } [Fact] public void Month_to_date_day_buckets_pair_with_the_same_days_of_the_previous_month() { var current = Preset(PeriodPreset.MonthToDate); var comparison = Applicable(current, PreviousPeriod); var plan = BucketPlanner.Plan(current, BucketSize.Day); var pairs = ComparisonResolver.PairBuckets(current, comparison, plan.Buckets); Assert.Equal(plan.Buckets, pairs.Select(p => p.Current)); Assert.Equal(Enumerable.Range(1, 19).Select(d => Day(2026, 8, d)), pairs.Select(p => p.Comparison.FirstDay)); Assert.All(pairs, p => Assert.Equal(BucketSize.Day, p.Comparison.Size)); Assert.Equal(Utc(2026, 8, 19, 12, 37), pairs[^1].Comparison.To); AssertTiles(comparison, pairs); } [Fact] public void The_cut_short_current_month_pairs_with_a_cut_short_month_naming_its_whole_unit() { var current = Preset(PeriodPreset.Last12Months); var comparison = Applicable(current, PreviousYear); var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets); Assert.Equal(12, pairs.Count); Assert.All(pairs, p => Assert.Equal(p.Current.FirstDay.AddYears(-1), p.Comparison.FirstDay)); var september = pairs[^1].Comparison; Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 20), Day(2025, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay)); Assert.Equal(Utc(2025, 9, 19, 12, 37), september.To); AssertTiles(comparison, pairs); } [Fact] public void Day_buckets_of_February_pair_its_last_day_with_the_rest_of_January_so_the_pairs_add_up() { var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0)); var january = Applicable(february, PreviousPeriod); var pairs = ComparisonResolver.PairBuckets(february, january, BucketPlanner.Plan(february, BucketSize.Day).Buckets); Assert.Equal(28, pairs.Count); Assert.Equal((Day(2026, 1, 27), Day(2026, 1, 28)), (pairs[^2].Comparison.FirstDay, pairs[^2].Comparison.EndDay)); Assert.Equal((Day(2026, 1, 28), Day(2026, 2, 1)), (pairs[^1].Comparison.FirstDay, pairs[^1].Comparison.EndDay)); AssertTiles(january, pairs); } [Fact] public void Days_of_March_that_February_lacks_pair_with_empty_buckets() { var march = Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37)); var february = Applicable(march, PreviousPeriod); var pairs = ComparisonResolver.PairBuckets(march, february, BucketPlanner.Plan(march, BucketSize.Day).Buckets); Assert.Equal(31, pairs.Count); Assert.Equal((Day(2026, 2, 28), Day(2026, 3, 1)), (pairs[27].Comparison.FirstDay, pairs[27].Comparison.EndDay)); Assert.All(pairs.Skip(28), p => Assert.Equal((p.Comparison.From, p.Comparison.FirstDay), (p.Comparison.To, p.Comparison.EndDay))); AssertTiles(february, pairs); } [Fact] public void Against_a_named_later_year_still_running_the_buckets_after_now_pair_with_empty_ones() { var current = Preset(PeriodPreset.PreviousYear); var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026)); var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets); Assert.Equal(12, pairs.Count); var september = pairs[8].Comparison; Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20), Day(2026, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay)); Assert.Equal(September19, september.To); Assert.All(pairs.Skip(9), p => { Assert.Equal((September19, September19), (p.Comparison.From, p.Comparison.To)); Assert.Equal(p.Comparison.FirstDay, p.Comparison.EndDay); Assert.True(p.Comparison.IsCutShort); }); AssertTiles(comparison, pairs); } [Fact] public void All_history_without_data_has_no_current_period_to_compare() { var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin); Assert.Equal(ComparisonUnavailableReason.NoCurrentPeriod, Reason(none, PreviousYear)); } [Fact] public void A_comparison_outside_the_supported_years_is_reported_as_out_of_range_never_thrown() { var year1900 = Custom(Day(1900, 1, 1), Day(1900, 12, 31)); var yearToDate = Preset(PeriodPreset.YearToDate); Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousYear)); Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousPeriod)); foreach (var year in new[] { 1, 1899, 2300, 9999, int.MinValue, int.MaxValue }) { Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, year))); } } private static void AssertTiles(ComparisonPeriod comparison, IReadOnlyList pairs) { Assert.Equal(comparison.From, pairs[0].Comparison.From); Assert.Equal(comparison.To, pairs[^1].Comparison.To); for (var i = 1; i < pairs.Count; i++) { Assert.Equal(pairs[i - 1].Comparison.To, pairs[i].Comparison.From); } } [Fact] public void Behind_UTC_the_cut_off_keeps_the_wall_clock_time_across_the_DST_change() { // 19 November noon in New York is EST (-5); 19 October noon was EDT (-4). var now = At(NewYork, 2026, 11, 19, 12, 0); var october = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod); Assert.Equal((Utc(2026, 10, 1, 4), Utc(2026, 10, 19, 16)), (october.From, october.To)); } [Fact] public void A_custom_month_reaching_past_now_compares_like_month_to_date() { var custom = Applicable(Custom(Day(2026, 9, 1), Day(2026, 9, 30)), PreviousPeriod); var monthToDate = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod); Assert.Equal(monthToDate, custom); } [Fact] public void A_custom_range_of_days_shifts_back_by_its_own_length() { var comparison = Applicable(Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 10), comparison.Shift); Assert.Equal((Day(2026, 8, 31), Day(2026, 9, 9)), (comparison.FirstDay, comparison.LastDay)); Assert.Equal(Utc(2026, 9, 9, 12, 37), comparison.To); } [Fact] public void A_custom_quarter_compares_with_the_quarter_before() { var comparison = Applicable(Custom(Day(2026, 1, 1), Day(2026, 3, 31)), PreviousPeriod); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 3), comparison.Shift); Assert.Equal((Day(2025, 10, 1), Day(2025, 12, 31)), (comparison.FirstDay, comparison.LastDay)); Assert.Equal(Utc(2025, 12, 31, 23), comparison.To); } [Fact] public void A_complete_year_compares_with_the_year_before_or_any_named_year() { var year2025 = Preset(PeriodPreset.PreviousYear); var year2024 = Applicable(year2025, PreviousPeriod); var year2020 = Applicable(year2025, new ComparisonRequest(ComparisonKind.Year, 2020)); Assert.Equal((Utc(2023, 12, 31, 23), Utc(2024, 12, 31, 23)), (year2024.From, year2024.To)); Assert.Equal((Day(2020, 1, 1), Day(2020, 12, 31)), (year2020.FirstDay, year2020.LastDay)); } [Fact] public void A_named_later_year_still_in_progress_is_capped_at_now() { var comparison = Applicable(Preset(PeriodPreset.PreviousYear), new ComparisonRequest(ComparisonKind.Year, 2026)); Assert.Equal(Utc(2025, 12, 31, 23), comparison.From); Assert.Equal(September19, comparison.To); Assert.True(comparison.CappedAtNow); Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, -1), comparison.Shift); } [Fact] public void Comparisons_that_cannot_apply_say_why() { var yearToDate = Preset(PeriodPreset.YearToDate); Assert.Equal(ComparisonUnavailableReason.NotRequested, Reason(yearToDate, ComparisonRequest.None)); Assert.Equal( ComparisonUnavailableReason.AllHistory, Reason(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1)), PreviousYear)); Assert.Equal( ComparisonUnavailableReason.NotYearAligned, Reason(Preset(PeriodPreset.MonthToDate), new ComparisonRequest(ComparisonKind.Year, 2024))); Assert.Equal( ComparisonUnavailableReason.NotYearAligned, Reason(Preset(PeriodPreset.Last12Months), new ComparisonRequest(ComparisonKind.Year, 2024))); Assert.Equal( ComparisonUnavailableReason.NotYearAligned, Reason(Custom(Day(2023, 1, 1), Day(2024, 12, 31)), new ComparisonRequest(ComparisonKind.Year, 2020))); Assert.Equal(ComparisonUnavailableReason.SameYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2026))); Assert.Equal( ComparisonUnavailableReason.ComparisonNotYetOccurred, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2027))); Assert.Equal(ComparisonUnavailableReason.MissingYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year))); Assert.Equal( ComparisonUnavailableReason.CurrentNotYetOccurred, Reason(Custom(Day(2027, 1, 1), Day(2027, 1, 31)), PreviousPeriod)); } [Fact] public void In_December_the_last_12_months_are_a_calendar_year_and_accept_a_named_year() { var current = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 12, 10, 12, 0)); var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2024)); Assert.Equal(Day(2024, 1, 1), comparison.FirstDay); Assert.Equal(Utc(2024, 12, 10, 11), comparison.To); } [Fact] public void Shifting_off_the_representable_calendar_is_reported_rather_than_thrown() { var utc = TimeZoneInfo.Utc; var yearOne = new ResolvedPeriod( PeriodPreset.Custom, new DateOnly(1, 1, 1), new DateOnly(1, 12, 31), new DateTimeOffset(1, 1, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2, 1, 1, 0, 0, 0, TimeSpan.Zero), September19, IsToDate: false, ExtendsPastNow: false, utc); Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearOne, PreviousYear)); } }