using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; namespace MeterVault.Core.Tests.Analysis; /// /// The resolution classes of D-13, at their exact limits: an hour and a minute, 25 hours, seven days and /// an hour, 31 days and two hours. The slack is what keeps real intervals — a late poll, a DST day, a /// month with a DST hour — in the class they belong to. There is one classifier (A-09); the bucket side of /// the same scale — which class a bucket size needs — is pinned here too. /// public sealed class ResolutionClassifierTests { public static TheoryData Limits => new() { { TimeSpan.Zero, ResolutionClass.Hour }, { TimeSpan.FromMinutes(5), ResolutionClass.Hour }, { TimeSpan.FromMinutes(60), ResolutionClass.Hour }, { TimeSpan.FromMinutes(61), ResolutionClass.Hour }, { TimeSpan.FromMinutes(61) + TimeSpan.FromTicks(1), ResolutionClass.Day }, { TimeSpan.FromHours(23), ResolutionClass.Day }, { TimeSpan.FromHours(25), ResolutionClass.Day }, { TimeSpan.FromHours(25) + TimeSpan.FromSeconds(1), ResolutionClass.Week }, { TimeSpan.FromDays(7) + TimeSpan.FromHours(1), ResolutionClass.Week }, { TimeSpan.FromDays(7) + TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1), ResolutionClass.Month }, { TimeSpan.FromDays(31) + TimeSpan.FromHours(1), ResolutionClass.Month }, { TimeSpan.FromDays(31) + TimeSpan.FromHours(2), ResolutionClass.Month }, { TimeSpan.FromDays(31) + TimeSpan.FromHours(2) + TimeSpan.FromSeconds(1), ResolutionClass.Coarse }, { TimeSpan.FromDays(365 * 12), ResolutionClass.Coarse }, }; [Theory] [MemberData(nameof(Limits))] public void An_interval_is_classified_by_its_length_up_to_each_limit_inclusive(TimeSpan length, ResolutionClass expected) { Assert.Equal(expected, ResolutionClassifier.Classify(length)); } [Fact] public void A_negative_length_claims_no_time_and_is_the_finest_class() { Assert.Equal(ResolutionClass.Hour, ResolutionClassifier.Classify(TimeSpan.FromHours(-3))); } [Fact] public void The_long_autumn_day_is_still_a_day_and_october_with_its_extra_hour_still_a_month() { Assert.Equal(TimeSpan.FromHours(25), InBerlin(2026, 10, 26) - InBerlin(2026, 10, 25)); Assert.Equal(ResolutionClass.Day, ResolutionClassifier.Classify(InBerlin(2026, 10, 25), InBerlin(2026, 10, 26))); Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 10, 1), InBerlin(2026, 11, 1))); Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2027, 2, 1), InBerlin(2027, 3, 1))); } [Fact] public void A_week_read_an_hour_late_is_still_a_week_but_two_weeks_are_not() { Assert.Equal(ResolutionClass.Week, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 11))); Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 21, 10))); } [Theory] [InlineData(ResolutionClass.Hour, 61)] [InlineData(ResolutionClass.Day, 25 * 60)] [InlineData(ResolutionClass.Week, (7 * 24 * 60) + 60)] [InlineData(ResolutionClass.Month, (31 * 24 * 60) + 120)] public void Each_class_admits_intervals_up_to_its_own_limit(ResolutionClass resolution, int minutes) { Assert.Equal(TimeSpan.FromMinutes(minutes), ResolutionClassifier.LimitOf(resolution)); Assert.Equal(resolution, ResolutionClassifier.Classify(ResolutionClassifier.LimitOf(resolution))); } [Fact] public void Data_coarser_than_a_month_has_no_limit() { Assert.Equal(TimeSpan.MaxValue, ResolutionClassifier.LimitOf(ResolutionClass.Coarse)); } [Theory] [InlineData(BucketSize.Day, ResolutionClass.Day)] [InlineData(BucketSize.Week, ResolutionClass.Week)] [InlineData(BucketSize.Month, ResolutionClass.Month)] [InlineData(BucketSize.Year, ResolutionClass.Month)] public void A_bucket_size_names_the_coarsest_class_that_resolves_it_outright(BucketSize size, ResolutionClass expected) { Assert.Equal(expected, ResolutionClassifier.CoarsestResolving(size)); } [Fact] public void Auto_has_no_class_because_it_is_resolved_before_any_bucket_exists() { Assert.Throws(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto)); } // ---- What a plotted run asks the bucket planner for (A-41) --------------------------------------- private static CoverageRun Interval(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) => new(from, to, resolution, divided, CoverageGapReason.None, from); [Fact] public void Quarterly_intervals_inside_one_year_ask_for_years() { // Read on the quarter: every interval lies inside 2025, so year buckets hold each one whole. CoverageRun[] quarters = [ Interval(InBerlin(2025, 1, 1), InBerlin(2025, 4, 1), ResolutionClass.Coarse), Interval(InBerlin(2025, 4, 1), InBerlin(2025, 7, 1), ResolutionClass.Coarse), Interval(InBerlin(2025, 10, 1), InBerlin(2026, 1, 1), ResolutionClass.Coarse), ]; Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution(quarters, Berlin)); Assert.Equal(BucketSize.Year, BucketPlanner.MinimumSizeFor(ResolutionClassifier.PlanningResolution(quarters, Berlin)!.Value)); } [Fact] public void One_interval_across_a_new_year_keeps_the_whole_chart_at_months() { // A dipstick read every autumn straddles the New Year as surely as every month start: year buckets leave it // exactly as unresolved as month buckets do, so it must not coarsen the chart to years (A-41). A chart has // one bucket size, so one such interval settles it for all of them. var dipsticks = Interval(InBerlin(2025, 9, 20, 10), InBerlin(2026, 8, 1, 10), ResolutionClass.Coarse); var quarter = Interval(InBerlin(2026, 1, 5), InBerlin(2026, 5, 1), ResolutionClass.Coarse); Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([dipsticks], Berlin)); Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution([quarter], Berlin)); Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([quarter, dipsticks], Berlin)); } [Fact] public void A_run_divided_at_the_month_edges_asks_for_months_whatever_its_length() { // A-03: a 46-day interval divided at 1 September is booked inside each month it touches. var divided = Interval(InBerlin(2026, 8, 20, 9), InBerlin(2026, 10, 5, 9), ResolutionClass.Coarse, divided: true); Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([divided], Berlin)); } [Fact] public void Gap_runs_and_an_empty_range_ask_for_nothing() { var hole = new CoverageRun(InBerlin(2025, 1, 1), InBerlin(2026, 6, 1), ResolutionClass.Hour, false, CoverageGapReason.SampleGap); Assert.Null(ResolutionClassifier.PlanningResolution([], Berlin)); Assert.Null(ResolutionClassifier.PlanningResolution([hole], Berlin)); } [Theory] [InlineData(ResolutionClass.Hour)] [InlineData(ResolutionClass.Day)] [InlineData(ResolutionClass.Week)] [InlineData(ResolutionClass.Month)] public void A_run_a_bucket_size_can_place_asks_for_its_own_class(ResolutionClass resolution) { var run = Interval(InBerlin(2025, 12, 20), InBerlin(2026, 1, 3), resolution); Assert.Equal(resolution, ResolutionClassifier.PlanningResolution([run], Berlin)); } [Fact] public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value() { Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Coarsest(ResolutionClass.Hour, ResolutionClass.Month)); Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.Coarsest(ResolutionClass.Coarse, ResolutionClass.Day)); } }