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