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