Files
MeterVault/tests/Core.Tests/Analysis/ComparisonResolverTests.cs
T
Florian Schmidt 8940ef25c3
ci / build-test (push) Successful in 2m31s
Analysis: one selected period, one set of numbers, on every page
The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
2026-09-20 10:29:13 +02:00

833 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// 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.
/// </summary>
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<string> 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<BucketPair> 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));
}
}