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