using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Totals;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
///
/// The pure pieces the Infrastructure reader rests on: a period as one bucket, lifecycle zeros (D-24), available
/// ranges (D-19) and the sum of a measure's members (D-22).
///
public sealed class ReaderSupportTests
{
private static readonly DateTimeOffset Now = At(2026, 9, 19, 14, 37);
[Theory]
[InlineData(2025, 1, 1, 2025, 12, 31, BucketSize.Year)]
[InlineData(2024, 1, 1, 2025, 12, 31, BucketSize.Year)]
[InlineData(2026, 2, 1, 2026, 2, 28, BucketSize.Month)]
[InlineData(2025, 10, 1, 2026, 9, 30, BucketSize.Month)]
[InlineData(2026, 9, 14, 2026, 9, 20, BucketSize.Week)]
[InlineData(2026, 9, 15, 2026, 9, 21, BucketSize.Day)]
[InlineData(2026, 1, 15, 2026, 3, 31, BucketSize.Day)]
public void A_period_is_the_coarsest_unit_it_is_made_of(int y1, int m1, int d1, int y2, int m2, int d2, BucketSize expected) =>
Assert.Equal(expected, PeriodBucket.AlignedSize(new DateOnly(y1, m1, d1), new DateOnly(y2, m2, d2)));
[Fact]
public void A_year_to_date_is_a_year_cut_at_now()
{
var period = PeriodResolver.Resolve(PeriodPreset.YearToDate, null, null, Now, Berlin);
var bucket = PeriodBucket.Of(period);
Assert.Equal(BucketSize.Year, bucket.Size);
Assert.Equal(new DateOnly(2026, 1, 1), bucket.FirstDay);
Assert.Equal(new DateOnly(2026, 9, 20), bucket.EndDay);
Assert.Equal(period.From, bucket.From);
Assert.Equal(Now, bucket.To);
Assert.Equal(new DateOnly(2027, 1, 1), bucket.NominalEndDay);
}
[Fact]
public void A_monthly_source_resolves_the_month_as_a_period_although_not_its_days()
{
// The reason totals get their own status: last month by day is unresolved day by day, resolved as a month.
var period = PeriodResolver.Resolve(PeriodPreset.LastMonth, null, null, Now, Berlin);
var run = new CoverageRun(Midnight(new DateOnly(2026, 8, 1)), Midnight(new DateOnly(2026, 9, 1)), ResolutionClass.Month, DividedAtMonths: true);
var total = CoverageEvaluator.Evaluate(PeriodBucket.Of(period), [run], Berlin, openingBalanceInBucket: false, Now);
var day = CoverageEvaluator.Evaluate(Day(2026, 8, 10), [run], Berlin, openingBalanceInBucket: false, Now);
Assert.Equal(BucketStatus.Available, total.Status);
Assert.Equal(BucketStatus.Unresolved, day.Status);
}
[Fact]
public void A_period_that_has_not_started_is_an_empty_bucket()
{
var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), Now, Berlin);
var bucket = PeriodBucket.Of(period);
Assert.Equal(bucket.From, bucket.To);
Assert.Equal(bucket.FirstDay, bucket.EndDay);
}
[Fact]
public void Time_outside_the_service_period_is_known_zero_coverage()
{
var runs = LifecycleCoverage.OutsideService(new DateOnly(2026, 2, 10), new DateOnly(2026, 6, 30), Berlin);
Assert.Equal(2, runs.Count);
Assert.Equal(Midnight(new DateOnly(2026, 2, 10)), runs[0].To);
Assert.Equal(Midnight(new DateOnly(2026, 7, 1)), runs[1].From);
Assert.All(runs, r => Assert.False(r.IsGap));
Assert.All(runs, r => Assert.Equal(ResolutionClass.Hour, r.Resolution));
Assert.Empty(LifecycleCoverage.OutsideService(null, null, Berlin));
}
[Fact]
public void A_meter_installed_mid_month_contributes_a_whole_month()
{
// Installed 10 February with daily data from then: as its own series February is partial, as a member of a
// total the days before installation are a known zero and February is complete (D-24).
var data = new CoverageRun(Midnight(new DateOnly(2026, 2, 10)), Midnight(new DateOnly(2026, 3, 1)), ResolutionClass.Day, DividedAtMonths: true);
var february = Month(2026, 2);
var own = CoverageEvaluator.Evaluate(february, [data], Berlin, false, Now);
var contributing = CoverageEvaluator.Evaluate(
february, LifecycleCoverage.WithService([data], new DateOnly(2026, 2, 10), null, Berlin), Berlin, false, Now);
Assert.Equal(BucketStatus.Partial, own.Status);
Assert.Equal(BucketStatus.Available, contributing.Status);
}
[Fact]
public void A_retired_meters_zero_is_known_only_up_to_now()
{
var runs = LifecycleCoverage.OutsideService(null, new DateOnly(2026, 6, 30), Berlin);
var capped = CoverageRuns.CapAt(runs, Now, Berlin);
Assert.Equal(Now, Assert.Single(capped).To);
}
[Fact]
public void Available_range_is_capped_at_now_and_names_its_latest_month()
{
// A current-month label row covers September but closes after now: availability ends where it starts.
var runs = new[]
{
new CoverageRun(Midnight(new DateOnly(2025, 11, 1)), Midnight(new DateOnly(2026, 10, 1)), ResolutionClass.Month, true,
LastIntervalStart: Midnight(new DateOnly(2026, 9, 1))),
};
var range = AvailableRange.OfRuns(runs, Now, Berlin);
Assert.NotNull(range);
Assert.Equal(new DateOnly(2025, 11, 1), range.FirstDay);
Assert.Equal(new DateOnly(2026, 8, 31), range.LastDay);
Assert.Equal(new DateOnly(2026, 8, 1), range.LatestMonth);
Assert.Null(AvailableRange.OfRuns([], Now, Berlin));
}
[Fact]
public void Available_ranges_unite_to_their_outer_bounds()
{
var early = AvailableRange.Of(Midnight(new DateOnly(2022, 11, 1)), Midnight(new DateOnly(2023, 1, 1)), Berlin);
var late = AvailableRange.Of(Midnight(new DateOnly(2026, 5, 1)), Midnight(new DateOnly(2026, 6, 1)), Berlin);
var union = AvailableRange.Union([early, null, late], Berlin);
Assert.NotNull(union);
Assert.Equal(new DateOnly(2022, 11, 1), union.FirstDay);
Assert.Equal(new DateOnly(2026, 5, 31), union.LastDay);
Assert.Equal(new DateOnly(2026, 5, 1), union.LatestMonth);
Assert.Null(AvailableRange.Union([null], Berlin));
}
[Fact]
public void Measure_members_add_up_when_all_are_available()
{
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Available(150, Provenance.Imported))]);
Assert.Equal(BucketStatus.Available, sum.Status);
Assert.Equal(250, sum.Value);
Assert.Equal(Provenance.Measured | Provenance.Imported, sum.Provenance);
}
[Fact]
public void A_missing_member_makes_the_total_partial_and_names_it()
{
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Missing())]);
Assert.Equal(BucketStatus.Partial, sum.Status);
Assert.Equal(100, sum.Value);
Assert.Equal(ValueIssue.MissingSource, sum.Issue);
Assert.Equal([2], sum.DependencyPath);
}
[Fact]
public void Without_any_member_data_the_total_is_missing()
{
var sum = MeasureValues.Sum([(1, BucketValue.Missing()), (2, BucketValue.Missing())]);
Assert.Equal(BucketStatus.Missing, sum.Status);
Assert.Null(sum.Value);
}
[Theory]
[InlineData(BucketStatus.Pending, ValueIssue.AnalysisPending)]
[InlineData(BucketStatus.Invalid, ValueIssue.NonFinite)]
[InlineData(BucketStatus.Unresolved, ValueIssue.CoarseResolution)]
public void A_spoiling_member_decides_the_total(BucketStatus status, ValueIssue issue)
{
var spoiling = new BucketValue(null, status, Provenance.None, issue, null, [7, 3]);
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (7, spoiling), (2, BucketValue.Missing())]);
Assert.Equal(status, sum.Status);
Assert.Null(sum.Value);
Assert.Equal(issue, sum.Issue);
Assert.Equal([7, 3], sum.DependencyPath);
}
[Fact]
public void A_partial_member_keeps_its_issue_and_signed_values_stay_signed()
{
var partial = new BucketValue(-40, BucketStatus.Partial, Provenance.Derived, ValueIssue.OpeningBalance);
var sum = MeasureValues.Sum([(1, BucketValue.Available(-10, Provenance.Measured)), (5, partial)]);
Assert.Equal(BucketStatus.Partial, sum.Status);
Assert.Equal(-50, sum.Value);
Assert.Equal(ValueIssue.OpeningBalance, sum.Issue);
Assert.Equal([5], sum.DependencyPath);
}
[Fact]
public void A_series_of_members_is_summed_bucket_by_bucket()
{
IReadOnlyList a = [BucketValue.Available(100, Provenance.Measured), BucketValue.Available(80, Provenance.Measured)];
IReadOnlyList b = [BucketValue.Available(150, Provenance.Measured), BucketValue.Missing()];
var series = MeasureValues.SumSeries([(1, a), (2, b)], 2);
Assert.Equal(250, series[0].Value);
Assert.Equal(BucketStatus.Available, series[0].Status);
Assert.Equal(80, series[1].Value);
Assert.Equal(BucketStatus.Partial, series[1].Status);
Assert.Throws(() => MeasureValues.SumSeries([(1, a)], 3));
}
private static AnalysisBucket Month(int year, int month)
{
var first = new DateOnly(year, month, 1);
return new AnalysisBucket(first, first.AddMonths(1), Midnight(first), Midnight(first.AddMonths(1)), BucketSize.Month);
}
}