using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
///
/// A change figure is confident only over the time both periods cover (D-07). What these pin down: the
/// match follows the current data's actual end rather than the period's; it trims to whole months where
/// either side only has monthly data, and to interval edges inside undivided intervals; a bound on a reading
/// instant keeps the row closing there on the side its time lies on; every source of a combined scope has to
/// agree; holes split the match; an opening balance stays out of it; and no overlap means "not comparable"
/// rather than a percentage against nothing.
///
public sealed class MatchedCoverageTests
{
private static readonly DateTimeOffset Now = At(2026, 9, 19, 10);
private static readonly TimeSpan Past = MatchedCoverage.PastBookedRow;
private static DateTimeOffset PreviousYear(DateTimeOffset instant) => ShiftYears(instant, -1);
private static ResolvedPeriod YearToDate(DateTimeOffset now) =>
Period(PeriodPreset.YearToDate, new DateOnly(now.Year, 1, 1), DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime), now);
private static MatchedCoverageResult MatchYearToDate(
DateTimeOffset now,
CoverageRun[] currentRuns,
CoverageRun[] comparisonRuns,
DateTimeOffset[]? currentOpeningBalances = null,
DateTimeOffset[]? comparisonOpeningBalances = null)
{
var current = YearToDate(now);
return MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, currentRuns, currentOpeningBalances),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), now, Berlin, comparisonRuns, comparisonOpeningBalances),
PreviousYear);
}
[Fact]
public void Year_to_date_with_data_until_31_May_matches_January_to_May_in_both_years()
{
var result = MatchYearToDate(Now, [MonthLabels(2026, 1, 2026, 6)], [MonthLabels(2025, 1, 2026, 1)]);
Assert.True(result.IsComparable);
Assert.True(result.IsContiguous);
Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 6, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 5, 31)), result.Current);
Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 6, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 5, 31)), result.Comparison);
}
[Fact]
public void Hourly_data_on_both_sides_matches_up_to_the_cut_at_now()
{
var result = MatchYearToDate(
Now,
[Run(At(2025, 6, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 6, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(Now, result.Current.To);
Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19, 10), result.Comparison!.To);
}
[Fact]
public void Monthly_data_on_the_comparison_side_trims_a_mid_month_cut_to_whole_months_on_both_sides()
{
// Month-resolution data cannot say how much of May had accrued by the 19th.
var now = At(2026, 5, 19, 14, 37);
var result = MatchYearToDate(
now,
[Run(At(2025, 12, 1), At(2026, 5, 19, 14, 37), ResolutionClass.Hour)],
[MonthLabels(2025, 1, 2026, 1)]);
Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 5, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 30)), result.Current);
Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 5, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 30)), result.Comparison);
}
[Fact]
public void Daily_readings_match_up_to_the_last_reading_before_now_and_include_its_row()
{
// Read at 06:00 daily; the reading of 20 September is already stamped. The interval containing now is
// given up (A-04), and the match ends on today's 06:00 reading, whose row closes the day before it.
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 31, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(At(2026, 9, 19, 6) + Past, result.Current.To);
Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19, 6), result.Comparison!.To);
}
[Fact]
public void A_cut_inside_daily_readings_falls_back_to_local_midnight()
{
// The comparison year was read at 06:00 daily; 10:00 on 19 September lies inside one of its days.
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 12, 31, 6), At(2025, 12, 1, 6), ResolutionClass.Day, divided: true)]);
Assert.Equal(At(2026, 9, 19), result.Current!.To);
Assert.Equal(new DateOnly(2026, 9, 18), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19), result.Comparison!.To);
}
[Fact]
public void An_undivided_tank_interval_open_at_the_cut_is_left_out_of_the_match()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 8, 5), ResolutionClass.Month), Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)],
[Run(At(2025, 1, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 8, 5), result.Current!.To);
Assert.Equal(At(2025, 8, 5), result.Comparison!.To);
}
[Fact]
public void Trimming_month_data_never_reaches_back_into_the_long_interval_before_it()
{
// Monthly tank readings resumed on 3 May after a 44-day interval. Floored to 1 May, the cut would
// land inside that interval; the reading on 3 May is the last edge the data can end on.
var now = At(2026, 5, 19, 14);
var result = MatchYearToDate(
now,
[
Run(At(2026, 1, 1), At(2026, 3, 20), ResolutionClass.Month),
Run(At(2026, 3, 20), At(2026, 5, 3), ResolutionClass.Coarse),
Run(At(2026, 5, 3), At(2026, 6, 2), ResolutionClass.Month),
],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 5, 3), result.Current!.To);
Assert.Equal(At(2025, 5, 3), result.Comparison!.To);
}
[Fact]
public void A_match_ending_on_a_dipstick_reading_includes_the_row_booked_at_that_reading()
{
// m2 #3 (P4): the row for 20 March → 3 May is stamped at 3 May 10:00. A query ending exactly there
// would leave those 44 days out of the current side while the comparison year keeps them.
var now = At(2026, 5, 19, 14);
var result = MatchYearToDate(
now,
[
Run(At(2026, 1, 1), At(2026, 3, 20, 10), ResolutionClass.Month),
Single(At(2026, 3, 20, 10), At(2026, 5, 3, 10), ResolutionClass.Coarse),
Single(At(2026, 5, 3, 10), At(2026, 6, 2, 10), ResolutionClass.Month),
],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 5, 3, 10) + Past, result.Current!.To);
Assert.Equal(new DateOnly(2026, 5, 3), result.Current.LastDay);
Assert.Equal(At(2025, 5, 3, 10), result.Comparison!.To);
}
[Fact]
public void A_comparison_month_cut_inside_undivided_dipstick_intervals_is_cut_at_the_dipsticks()
{
// m2 #3 (P7): November 2026 hourly against November 2025, read on 20 October, 18 and 29 November and
// 20 December. 1 November and 1 December lie inside undivided intervals; their dipsticks are the only
// cut points, and the start moves past the row closing at the first one.
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5));
var comparisonRuns = new[]
{
Single(At(2025, 10, 20, 10), At(2025, 11, 18, 10), ResolutionClass.Month),
Single(At(2025, 11, 18, 10), At(2025, 11, 29, 10), ResolutionClass.Month, divided: true),
Single(At(2025, 11, 29, 10), At(2025, 12, 20, 10), ResolutionClass.Month),
};
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), current.Now, Berlin, comparisonRuns),
PreviousYear);
Assert.Equal(new MatchedRange(At(2025, 11, 18, 10) + Past, At(2025, 11, 29, 10) + Past, new DateOnly(2025, 11, 18), new DateOnly(2025, 11, 29)), result.Comparison);
Assert.Equal(new MatchedRange(At(2026, 11, 18, 10), At(2026, 11, 29, 10), new DateOnly(2026, 11, 18), new DateOnly(2026, 11, 29)), result.Current);
}
[Fact]
public void A_comparison_month_that_one_undivided_interval_straddles_on_both_ends_is_not_comparable()
{
// m2 #3 (P7): 14 October → 28 November 10:00 → 20 December. No dipstick falls where November can be cut.
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5));
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]),
MatchSide.Of(
PreviousYear(current.From),
PreviousYear(current.To),
current.Now,
Berlin,
[Single(At(2025, 10, 14), At(2025, 11, 28, 10), ResolutionClass.Coarse), Single(At(2025, 11, 28, 10), At(2025, 12, 20), ResolutionClass.Month)]),
PreviousYear);
Assert.False(result.IsComparable);
}
[Fact]
public void A_combined_scope_starts_its_match_where_every_source_can_be_cut()
{
// m2 #4 (P1): source A books 1 January – 15 March in one interval, then reads daily; source B starts
// hourly on 1 March. Together they cover from 1 March, but A's interval can only be cut at its end.
var current = YearToDate(Now);
IReadOnlyList[] currentSources =
[
[Single(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 12, 1), ResolutionClass.Day, divided: true)],
[Run(At(2026, 3, 1), At(2026, 12, 1), ResolutionClass.Hour)],
];
IReadOnlyList[] comparisonSources =
[
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
];
var result = MatchedCoverage.Match(
MatchSide.OfSources(current.From, current.To, Now, Berlin, currentSources),
MatchSide.OfSources(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, comparisonSources),
PreviousYear);
Assert.Equal(At(2026, 3, 15), result.Current!.From);
Assert.Equal(At(2025, 3, 15), result.Comparison!.From);
}
[Fact]
public void A_scope_without_sources_is_not_comparable()
{
var current = YearToDate(Now);
var result = MatchedCoverage.Match(
MatchSide.OfSources(current.From, current.To, Now, Berlin, []),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, [MonthLabels(2025, 1, 2026, 1)]),
PreviousYear);
Assert.False(result.IsComparable);
}
[Fact]
public void A_meter_installed_in_March_last_year_matches_from_March_on_both_sides()
{
var result = MatchYearToDate(
Now,
[Run(At(2025, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 3, 10), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 3, 10), result.Current!.From);
Assert.Equal(new DateOnly(2026, 3, 10), result.Current.FirstDay);
Assert.Equal(At(2025, 3, 10), result.Comparison!.From);
Assert.Equal(Now, result.Current.To);
}
[Fact]
public void An_outage_in_the_comparison_year_splits_the_match_and_is_left_out_of_both_sides()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[
Run(At(2025, 1, 1), At(2025, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2025, 3, 10, 14), At(2025, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2025, 3, 13, 9), At(2026, 1, 1), ResolutionClass.Hour),
]);
Assert.False(result.IsContiguous);
Assert.Equal(2, result.Pieces.Count);
Assert.Equal(At(2026, 3, 10, 14), result.Pieces[0].Current.To);
Assert.Equal(At(2026, 3, 13, 9), result.Pieces[1].Current.From);
// The gap's row is booked at its end, 13 March 09:00: the second piece starts just past it, and the
// first ends just past the last sample before the outage.
Assert.Equal(At(2025, 3, 10, 14) + Past, result.Pieces[0].Comparison.To);
Assert.Equal(At(2025, 3, 13, 9) + Past, result.Pieces[1].Comparison.From);
// The spans still run from the first to the last matched instant.
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(Now, result.Current.To);
}
// ---- Opening balances (A-01) ----------------------------------------------------------------------
[Fact]
public void An_opening_balance_at_the_start_of_the_current_data_stays_out_of_the_match()
{
// D-14: the first reading's row holds consumption of unknown extent.
var firstReading = At(2026, 2, 3, 11);
var result = MatchYearToDate(
Now,
[Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
currentOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Current!.From);
Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay);
Assert.Equal(At(2025, 2, 3, 11), result.Comparison!.From);
}
[Fact]
public void An_opening_balance_read_at_midnight_stays_out_of_a_match_starting_there()
{
// m2 #2: a first reading at 00:00 describes no time before it, so its row stays on the midnight
// (D-11 only moves rows that close an interval) and a range starting there must step past it.
var firstReading = At(2026, 2, 3);
var result = MatchYearToDate(
Now,
[Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
currentOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Current!.From);
Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay);
Assert.Equal(At(2025, 2, 3), result.Comparison!.From);
}
[Fact]
public void An_opening_balance_in_the_comparison_year_moves_the_start_of_both_sides()
{
var firstReading = At(2025, 4, 7, 15);
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(firstReading, At(2026, 1, 1), ResolutionClass.Hour)],
comparisonOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Comparison!.From);
Assert.Equal(At(2026, 4, 7, 15), result.Current!.From);
}
// ---- Not comparable ---------------------------------------------------------------------------------
[Fact]
public void No_coverage_in_the_comparison_period_is_not_comparable()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 10, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.False(result.IsComparable);
Assert.Same(MatchedCoverageResult.NotComparable, result);
Assert.Null(result.Current);
Assert.Null(result.Comparison);
}
[Fact]
public void No_coverage_in_the_current_period_is_not_comparable()
{
var result = MatchYearToDate(Now, [], [MonthLabels(2025, 1, 2026, 1)]);
Assert.False(result.IsComparable);
}
[Fact]
public void An_overlap_shorter_than_the_monthly_side_can_resolve_is_not_comparable()
{
// Hourly data for 1-20 January against a year of month rows: January cannot be cut on the 20th.
var now = At(2026, 1, 20, 12);
var result = MatchYearToDate(now, [Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour)], [MonthLabels(2025, 1, 2026, 1)]);
Assert.False(result.IsComparable);
}
// ---- Calendar shifts ------------------------------------------------------------------------------
[Fact]
public void A_previous_period_shifted_by_days_is_trimmed_on_the_side_whose_data_is_coarse()
{
// 10-19 September against 31 August - 9 September, with the earlier stretch read once a day at noon.
var now = At(2026, 9, 19, 10);
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 19), now);
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 9, 1), At(2026, 10, 1), ResolutionClass.Hour)]),
MatchSide.Of(At(2026, 8, 31), ShiftDays(now, -10), now, Berlin, [Run(At(2026, 8, 30, 12), At(2026, 9, 12, 12), ResolutionClass.Day, divided: true)]),
t => ShiftDays(t, -10));
// The hourly side needs no trimming; the daily side ends its match at local midnight on 9 September.
Assert.Equal(At(2026, 9, 10), result.Current!.From);
Assert.Equal(At(2026, 9, 19), result.Current.To);
Assert.Equal(At(2026, 8, 31), result.Comparison!.From);
Assert.Equal(At(2026, 9, 9), result.Comparison.To);
}
[Fact]
public void A_match_across_the_spring_DST_change_lines_up_on_local_wall_time()
{
// DST began on 30 March 2025 and 29 March 2026; the matched ranges still start and end at local midnights.
var now = At(2026, 4, 15, 12);
var result = MatchYearToDate(
now,
[Run(At(2026, 3, 1), At(2026, 4, 16, 6), ResolutionClass.Day, divided: true)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 3, 1), result.Current!.From);
Assert.Equal(At(2026, 4, 15), result.Current.To);
Assert.Equal(At(2025, 3, 1), result.Comparison!.From);
Assert.Equal(At(2025, 4, 15), result.Comparison.To);
Assert.Equal(new DateOnly(2025, 4, 14), result.Comparison.LastDay);
}
[Fact]
public void A_match_on_the_long_autumn_day_cuts_both_years_at_the_same_wall_time()
{
// 25 October 2026 has 25 hours in Berlin; on 25 October 2025 the clocks had not gone back yet.
var now = At(2026, 10, 25, 12);
var hourly = MatchYearToDate(
now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
var daily = MatchYearToDate(
now,
[Run(At(2025, 12, 31, 6), At(2026, 12, 1, 6), ResolutionClass.Day, divided: true)],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(now, hourly.Current!.To);
Assert.Equal(At(2025, 10, 25, 12), hourly.Comparison!.To);
Assert.Equal(TimeSpan.FromHours(1), (hourly.Current.To - hourly.Current.From) - (hourly.Comparison.To - hourly.Comparison.From));
Assert.Equal(At(2026, 10, 25), daily.Current!.To);
Assert.Equal(At(2025, 10, 25), daily.Comparison!.To);
Assert.Equal(new DateOnly(2025, 10, 24), daily.Comparison.LastDay);
}
[Fact]
public void March_to_date_on_the_30th_matches_all_of_February_because_the_29th_to_31st_map_onto_its_end()
{
// m2 #11: shifting by a month clamps 29-31 March to the end of February (D-06), so the shift is not
// one-to-one there: every instant from 29 March 00:00 maps to 1 March 00:00.
var now = At(2026, 3, 30, 10);
var current = Period(PeriodPreset.MonthToDate, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30), now);
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 1, 1), At(2026, 5, 1), ResolutionClass.Hour)]),
MatchSide.Of(At(2026, 2, 1), At(2026, 3, 1), now, Berlin, [Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Hour)]),
t => ShiftMonths(t, -1));
Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 29), -1));
Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 31, 23), -1));
Assert.Equal(new MatchedRange(At(2026, 3, 1), now, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30)), result.Current);
Assert.Equal(new MatchedRange(At(2026, 2, 1), At(2026, 3, 1), new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)), result.Comparison);
}
[Fact]
public void Year_to_date_on_29_February_matches_up_to_the_end_of_February_the_year_before()
{
// m2 #11: 29 February 2024 has no counterpart in 2023; the day maps onto 1 March 2023 00:00.
var now = At(2024, 2, 29, 10);
var result = MatchYearToDate(
now,
[Run(At(2023, 12, 31, 6), At(2024, 3, 10, 6), ResolutionClass.Day, divided: true)],
[Run(At(2022, 12, 1), At(2023, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(new MatchedRange(At(2024, 1, 1), At(2024, 2, 29), new DateOnly(2024, 1, 1), new DateOnly(2024, 2, 28)), result.Current);
Assert.Equal(new MatchedRange(At(2023, 1, 1), At(2023, 3, 1), new DateOnly(2023, 1, 1), new DateOnly(2023, 2, 28)), result.Comparison);
}
[Fact]
public void In_New_York_monthly_labels_match_on_New_York_month_starts()
{
var now = At(NewYork, 2026, 9, 19, 10);
var from = At(NewYork, 2026, 1, 1);
DateTimeOffset LastYear(DateTimeOffset t) => Shift(t, NewYork, months: -12);
var result = MatchedCoverage.Match(
MatchSide.Of(from, now, now, NewYork, [MonthLabels(NewYork, 2026, 1, 2026, 7)]),
MatchSide.Of(LastYear(from), LastYear(now), now, NewYork, [MonthLabels(NewYork, 2025, 1, 2026, 1)]),
LastYear);
Assert.Equal(new MatchedRange(At(NewYork, 2026, 1, 1), At(NewYork, 2026, 7, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 30)), result.Current);
Assert.Equal(new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero), result.Current!.To);
Assert.Equal(new MatchedRange(At(NewYork, 2025, 1, 1), At(NewYork, 2025, 7, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 6, 30)), result.Comparison);
}
}