using MeterVault.Core.Analysis; using static MeterVault.Core.Tests.Analysis.AnalysisClock; namespace MeterVault.Core.Tests.Analysis; /// /// A period resolves once, in the instance zone, into local dates for display and a half-open UTC range /// for queries (D-02 – D-04). What these pin down: ranges start at local midnight, never UTC midnight; /// "today" is the local date; to-date periods stop at the captured now; and every preset is computed from /// frozen instants, so none of this depends on when the tests run. /// public sealed class PeriodResolverTests { private static ResolvedPeriod Resolve(PeriodPreset preset, DateTimeOffset now, TimeZoneInfo? zone = null) => PeriodResolver.Resolve(preset, null, null, now, zone ?? Berlin); private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset now, TimeZoneInfo? zone = null) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now, zone ?? Berlin); [Fact] public void Month_to_date_in_Berlin_on_19_September_runs_from_local_midnight_on_the_1st_to_now() { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = Resolve(PeriodPreset.MonthToDate, now); Assert.Equal(Day(2026, 9, 1), period.FirstDay); Assert.Equal(Day(2026, 9, 19), period.LastDay); Assert.Equal(Utc(2026, 8, 31, 22), period.From); Assert.Equal(Utc(2026, 9, 19, 12, 37), period.To); Assert.Equal(now, period.Now); Assert.True(period.IsToDate); Assert.False(period.ExtendsPastNow); Assert.False(period.NotYetOccurred); Assert.Same(Berlin, period.Zone); // The named range is the whole month; comparisons and projections read that, the display reads the cut. Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay()); Assert.Equal(Day(2026, 9, 30), period.NominalLastDay()); Assert.Equal(Utc(2026, 9, 30, 22), period.NominalEnd()); } [Theory] [InlineData(PeriodPreset.LastMonth, "2026-08-01", "2026-08-31", "2026-07-31T22:00Z", "2026-08-31T22:00Z", false)] [InlineData(PeriodPreset.YearToDate, "2026-01-01", "2026-09-19", "2025-12-31T23:00Z", null, true)] [InlineData(PeriodPreset.PreviousYear, "2025-01-01", "2025-12-31", "2024-12-31T23:00Z", "2025-12-31T23:00Z", false)] [InlineData(PeriodPreset.Last12Months, "2025-10-01", "2026-09-19", "2025-09-30T22:00Z", null, true)] [InlineData(PeriodPreset.Last24Months, "2024-10-01", "2026-09-19", "2024-09-30T22:00Z", null, true)] public void Every_preset_on_19_September_resolves_to_its_local_calendar_range( PeriodPreset preset, string first, string last, string from, string? to, bool toDate) { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = Resolve(preset, now); Assert.Equal(Iso(first), period.FirstDay); Assert.Equal(Iso(last), period.LastDay); Assert.Equal(IsoInstant(from), period.From); Assert.Equal(to is null ? now : IsoInstant(to), period.To); Assert.Equal(toDate, period.IsToDate); Assert.False(period.ExtendsPastNow); } [Fact] public void The_last_12_months_are_twelve_calendar_months_ending_with_the_current_partial_one() { var period = Resolve(PeriodPreset.Last12Months, At(Berlin, 2026, 9, 19, 14, 37)); // Not 13 months, and no future month: October 2025 through September 2026, stopping now. Assert.Equal(Day(2025, 10, 1), period.FirstDay); Assert.Equal(Day(2026, 9, 30), period.NominalLastDay()); Assert.Equal(period.Now, period.To); } [Fact] public void Half_an_hour_into_New_Year_the_current_month_and_year_are_2027_although_UTC_is_still_in_2026() { var now = At(Berlin, 2027, 1, 1, 0, 30); Assert.Equal(Utc(2026, 12, 31, 23, 30), now.ToUniversalTime()); var month = Resolve(PeriodPreset.MonthToDate, now); var year = Resolve(PeriodPreset.YearToDate, now); var lastMonth = Resolve(PeriodPreset.LastMonth, now); var previousYear = Resolve(PeriodPreset.PreviousYear, now); var last12 = Resolve(PeriodPreset.Last12Months, now); Assert.Equal((Day(2027, 1, 1), Day(2027, 1, 1)), (month.FirstDay, month.LastDay)); Assert.Equal((Utc(2026, 12, 31, 23), Utc(2026, 12, 31, 23, 30)), (month.From, month.To)); Assert.Equal((month.From, month.To), (year.From, year.To)); Assert.Equal(Day(2027, 12, 31), year.NominalLastDay()); Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 31)), (lastMonth.FirstDay, lastMonth.LastDay)); Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 12, 31, 23)), (lastMonth.From, lastMonth.To)); Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (previousYear.FirstDay, previousYear.LastDay)); Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 12, 31, 23)), (previousYear.From, previousYear.To)); Assert.Equal(Day(2026, 2, 1), last12.FirstDay); Assert.Equal(Day(2027, 1, 1), last12.LastDay); } [Fact] public void A_month_containing_the_spring_DST_change_is_one_hour_short_and_starts_in_winter_time() { var march = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 1, 9, 0)); Assert.Equal(Utc(2026, 2, 28, 23), march.From); Assert.Equal(Utc(2026, 3, 31, 22), march.To); Assert.Equal(TimeSpan.FromHours((31 * 24) - 1), march.To - march.From); } [Fact] public void On_the_spring_DST_day_itself_month_to_date_runs_from_the_1st_in_winter_time_to_now_in_summer_time() { var now = At(Berlin, 2026, 3, 29, 10, 0); Assert.Equal(TimeSpan.FromHours(2), now.Offset); var period = Resolve(PeriodPreset.MonthToDate, now); Assert.Equal(Utc(2026, 2, 28, 23), period.From); Assert.Equal(Utc(2026, 3, 29, 8), period.To); Assert.Equal(Day(2026, 3, 29), period.LastDay); } [Fact] public void A_month_containing_the_autumn_DST_change_is_one_hour_long() { var october = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0)); Assert.Equal(Utc(2026, 9, 30, 22), october.From); Assert.Equal(Utc(2026, 10, 31, 23), october.To); Assert.Equal(TimeSpan.FromHours((31 * 24) + 1), october.To - october.From); } [Fact] public void In_the_repeated_autumn_hour_today_is_still_the_DST_day_and_the_cut_is_the_exact_instant() { // 02:30 in winter time, the second time the clock shows 02:30 on 25 October 2026. var now = At(2026, 10, 25, 2, 30, offsetHours: 1); var period = Resolve(PeriodPreset.MonthToDate, now); Assert.Equal(Day(2026, 10, 25), period.LastDay); Assert.Equal(Utc(2026, 10, 25, 1, 30), period.To); } [Fact] public void Behind_UTC_the_local_date_decides_the_month_even_when_UTC_has_moved_on() { // 21:00 on 30 September in New York is already 1 October in UTC. var now = At(NewYork, 2026, 9, 30, 21, 0); Assert.Equal(Utc(2026, 10, 1, 1), now.ToUniversalTime()); var period = Resolve(PeriodPreset.MonthToDate, now, NewYork); Assert.Equal(Day(2026, 9, 1), period.FirstDay); Assert.Equal(Day(2026, 9, 30), period.LastDay); Assert.Equal(Utc(2026, 9, 1, 4), period.From); Assert.Equal(Utc(2026, 10, 1, 1), period.To); } [Fact] public void Behind_UTC_a_month_ending_in_winter_time_spans_its_local_midnights() { // November 2026 starts in daylight time (EDT, -4) and ends in standard time (EST, -5). var november = Resolve(PeriodPreset.LastMonth, At(NewYork, 2026, 12, 1, 0, 30), NewYork); Assert.Equal(Utc(2026, 11, 1, 4), november.From); Assert.Equal(Utc(2026, 12, 1, 5), november.To); } [Fact] public void Every_instant_is_stored_in_UTC_whatever_offset_now_arrives_with() { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = Resolve(PeriodPreset.YearToDate, now); Assert.Equal(TimeSpan.Zero, period.From.Offset); Assert.Equal(TimeSpan.Zero, period.To.Offset); Assert.Equal(TimeSpan.Zero, period.Now.Offset); } [Fact] public void A_custom_range_in_the_past_is_complete_and_ends_at_the_local_midnight_after_its_last_day() { var period = Custom(Day(2026, 6, 1), Day(2026, 6, 30), At(Berlin, 2026, 9, 19, 14, 37)); Assert.Equal(Utc(2026, 5, 31, 22), period.From); Assert.Equal(Utc(2026, 6, 30, 22), period.To); Assert.False(period.IsToDate); Assert.False(period.ExtendsPastNow); } [Fact] public void A_custom_range_reaching_past_now_is_capped_at_now_and_keeps_the_requested_dates() { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31), now); Assert.Equal(Day(2026, 9, 1), period.FirstDay); Assert.Equal(Day(2026, 12, 31), period.LastDay); Assert.Equal(Utc(2026, 8, 31, 22), period.From); Assert.Equal(now, period.To); Assert.True(period.IsToDate); Assert.True(period.ExtendsPastNow); Assert.False(period.NotYetOccurred); Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay()); // Rows recorded after now are looked for up to the end of what was asked. Assert.Equal(Utc(2026, 12, 31, 23), period.NominalEnd()); } [Fact] public void A_custom_range_ending_today_is_cut_at_now_but_does_not_extend_past_it() { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = Custom(Day(2026, 9, 10), Day(2026, 9, 19), now); Assert.Equal(now, period.To); Assert.True(period.IsToDate); Assert.False(period.ExtendsPastNow); } [Fact] public void A_custom_range_entirely_in_the_future_has_not_occurred_and_an_empty_query_range() { var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37)); Assert.True(period.NotYetOccurred); Assert.Equal(Day(2027, 1, 1), period.FirstDay); Assert.Equal(Day(2027, 3, 31), period.LastDay); Assert.Equal(Utc(2026, 12, 31, 23), period.From); // Nothing can be counted as an actual: [From, To) is empty. Assert.Equal(period.From, period.To); Assert.False(period.IsToDate); Assert.True(period.ExtendsPastNow); Assert.False(period.HasNoHistory()); Assert.True(period.HasNotStarted()); } [Fact] public void A_range_that_has_not_started_has_no_effective_days_so_no_rollup_day_is_summed_as_an_actual() { // Summing daily rollups from FirstDay to EffectiveLastDay must not reach into 2027: every row there // is recorded after now (D-04). var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37)); Assert.Equal(Day(2026, 12, 31), period.EffectiveLastDay()); Assert.True(period.EffectiveLastDay() < period.FirstDay); } [Fact] public void The_effective_last_day_is_today_for_a_to_date_period_and_the_last_day_for_a_complete_one() { var now = At(Berlin, 2026, 9, 19, 14, 37); Assert.Equal(Day(2026, 9, 19), Custom(Day(2026, 9, 1), Day(2026, 12, 31), now).EffectiveLastDay()); Assert.Equal(Day(2026, 8, 31), Resolve(PeriodPreset.LastMonth, now).EffectiveLastDay()); Assert.Equal(Day(2026, 9, 18), PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin).EffectiveLastDay()); } [Fact] public void At_the_exact_local_midnight_that_starts_a_month_its_month_to_date_has_started_with_nothing_elapsed() { var now = At(Berlin, 2026, 10, 1); var period = Resolve(PeriodPreset.MonthToDate, now); // Like the current month of "last 12 months" at the same instant: today exists, and is empty. Assert.Equal((period.From, period.From), (period.To, period.Now)); Assert.True(period.IsToDate); Assert.False(period.HasNotStarted()); Assert.Equal((Day(2026, 10, 1), Day(2026, 10, 1)), (period.FirstDay, period.LastDay)); Assert.Equal(Day(2026, 10, 1), period.EffectiveLastDay()); } [Fact] public void At_the_exact_local_midnight_no_history_is_neither_started_nor_waiting_to_start() { var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 10, 1), Berlin); Assert.True(period.HasNoHistory()); Assert.False(period.HasNotStarted()); } [Theory] [InlineData(null, "2026-09-30")] [InlineData("2026-10-01", null)] [InlineData("2026-09-30", "2026-09-01")] [InlineData("1899-12-31", "2026-01-01")] [InlineData("2026-01-01", "2300-01-01")] public void A_custom_range_that_is_missing_reversed_or_out_of_bounds_is_rejected(string? first, string? last) { DateOnly? from = first is null ? null : Iso(first); DateOnly? to = last is null ? null : Iso(last); Assert.False(PeriodResolver.IsValidCustomRange(from, to)); Assert.Throws(() => PeriodResolver.Resolve(PeriodPreset.Custom, from, to, At(Berlin, 2026, 9, 19, 14, 37), Berlin)); } [Fact] public void All_history_spans_the_available_data_and_stops_at_its_last_day_when_that_is_in_the_past() { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31)); Assert.Equal(Day(1997, 1, 1), period.FirstDay); Assert.Equal(Day(2026, 5, 31), period.LastDay); Assert.Equal(Utc(1996, 12, 31, 23), period.From); Assert.Equal(Utc(2026, 5, 31, 22), period.To); Assert.False(period.IsToDate); } [Fact] public void All_history_of_live_data_runs_to_now() { var now = At(Berlin, 2026, 9, 19, 14, 37); var open = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)); var toToday = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2026, 9, 19)); Assert.Equal(now, open.To); Assert.True(open.IsToDate); Assert.Equal(Day(2026, 9, 19), open.LastDay); Assert.Equal(open, toToday); } [Fact] public void All_history_without_any_available_data_is_an_explicit_no_history_result() { var now = At(Berlin, 2026, 9, 19, 14, 37); var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin); Assert.True(period.HasNoHistory()); Assert.Equal(PeriodPreset.AllHistory, period.Preset); Assert.Equal(Day(2026, 9, 19), period.FirstDay); Assert.True(period.LastDay < period.FirstDay); Assert.Equal(period.From, period.To); Assert.Equal(Utc(2026, 9, 18, 22), period.From); // "No history" is not "not yet occurred", and nothing is to date. Assert.False(period.NotYetOccurred); Assert.False(period.IsToDate); Assert.False(period.ExtendsPastNow); } [Fact] public void Availability_whose_last_day_precedes_its_first_is_treated_as_no_history() { var period = PeriodResolver.Resolve( PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 9, 19, 14, 37), Berlin, Day(2026, 5, 1), Day(2026, 4, 30)); Assert.True(period.HasNoHistory()); } [Theory] [InlineData("0001-01-01")] [InlineData("0206-05-01")] [InlineData("1899-12-31")] public void All_history_reads_a_stray_reading_date_before_1900_as_1900_instead_of_throwing(string strayFirst) { // Availability comes from the data, not from a validated URL token: year 1 used to throw from the // local-midnight arithmetic in Berlin, and year 206 spanned 1,821 years. var now = At(Berlin, 2026, 9, 19, 14, 37); var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Iso(strayFirst)); Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay); Assert.Equal(Day(2026, 9, 19), period.LastDay); Assert.Equal(now, period.To); } [Fact] public void All_history_reads_availability_after_today_as_today_because_actuals_stop_at_now() { var now = At(Berlin, 2026, 9, 19, 14, 37); var future = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2027, 3, 1)); var onlyFuture = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2027, 1, 1)); Assert.Equal(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)), future); Assert.True(future.IsToDate); Assert.False(future.ExtendsPastNow); Assert.True(onlyFuture.HasNoHistory()); } [Fact] public void All_history_is_open_ended_so_rows_recorded_far_after_now_are_still_looked_for() { var now = At(Berlin, 2026, 9, 19, 14, 37); var live = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)); var historical = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31)); // A Tasmota row stamped in 2027 must reach the "recorded after now" block (D-04): no upper bound. Assert.Null(live.NominalEnd()); Assert.Null(historical.NominalEnd()); Assert.Equal(Day(2026, 9, 19), live.NominalLastDay()); // Every other preset names where it ends. Assert.Equal(Utc(2026, 12, 31, 23), Resolve(PeriodPreset.YearToDate, now).NominalEnd()); Assert.Equal(Utc(2026, 8, 31, 22), Resolve(PeriodPreset.LastMonth, now).NominalEnd()); } [Fact] public void Presets_ignore_custom_dates_and_custom_ignores_availability() { var now = At(Berlin, 2026, 9, 19, 14, 37); var month = PeriodPreset.MonthToDate; Assert.Equal( PeriodResolver.Resolve(month, null, null, now, Berlin), PeriodResolver.Resolve(month, Day(2020, 1, 1), Day(2020, 1, 31), now, Berlin, Day(1997, 1, 1))); var custom = PeriodResolver.Resolve(PeriodPreset.Custom, Day(2026, 6, 1), Day(2026, 6, 30), now, Berlin, Day(1997, 1, 1), Day(1998, 1, 1)); Assert.Equal(Day(2026, 6, 1), custom.FirstDay); } }