using MeterVault.App.Analysis; using MeterVault.App.MeterDetails; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData; namespace MeterVault.Integration.Tests.Analysis; /// /// Where the shared components lead (D-48, D-51, brief §3.1, §4.3, §5.1): drilling into a bucket keeps the scope and /// opens the next finer bucket the data supports, "go to latest data" keeps the kind of period, breadcrumbs carry the /// period up, and a formula names its meters. Pure; no database. /// public sealed class AnalysisNavigationTests { private static readonly AnalysisQuery History = AnalysisQuery.Default(AnalysisDefaults.History); [Fact] public void A_year_opens_its_months_and_a_month_its_days() { var year = Buckets(D(2025, 1, 1), D(2025, 12, 31), BucketSize.Year)[0]; var month = Buckets(D(2026, 2, 1), D(2026, 2, 28))[0]; var intoYear = AnalysisNavigation.DrillInto(History, year)!; Assert.Equal((D(2025, 1, 1), D(2025, 12, 31)), (intoYear.From!.Value, intoYear.To!.Value)); Assert.Equal(BucketSize.Month, intoYear.Bucket); Assert.Equal(History.Comparison, intoYear.Comparison); var intoMonth = AnalysisNavigation.DrillInto(History, month)!; Assert.Equal((D(2026, 2, 1), D(2026, 2, 28)), (intoMonth.From!.Value, intoMonth.To!.Value)); Assert.Equal(BucketSize.Day, intoMonth.Bucket); } [Fact] public void Only_a_bucket_the_data_resolves_is_offered() { var month = Buckets(D(2026, 2, 1), D(2026, 2, 28))[0]; var day = Buckets(D(2026, 2, 3), D(2026, 2, 3), BucketSize.Day)[0]; Assert.Equal(BucketSize.Week, AnalysisNavigation.DrillInto(History, month, ResolutionClass.Week)!.Bucket); Assert.Equal(BucketSize.Day, AnalysisNavigation.DrillInto(History, month, ResolutionClass.Hour)!.Bucket); // A monthly import has nothing finer than its month, and a day nothing finer than itself: open the records. Assert.Null(AnalysisNavigation.DrillInto(History, month, ResolutionClass.Month)); Assert.Null(AnalysisNavigation.DrillInto(History, day)); Assert.Equal("/meters/5?tab=normalized&from=2026-02-01&to=2026-02-28", AnalysisNavigation.NormalizedData(5, History, month)); } [Fact] public void A_virtual_meters_month_that_has_nothing_finer_opens_as_its_own_month_never_a_dead_end() { // Brief §3.2 "Explain a spike" for a virtual meter over monthly sources: a month cannot open days, and a virtual // meter has no records, so the month opens the meter's analysis over that month — whose source contributions link // to each source's records for it. On that one-month view the month leads nowhere, and nothing invites a click. var year = AnalysisQuery.Parse("?period=prev-year", MeterAnalysisLoader.DefaultsFor(9)); var shownYear = Range(D(2025, 1, 1), D(2025, 12, 31)); var march = Buckets(D(2025, 1, 1), D(2025, 12, 31))[2]; Assert.Equal( "/meters/9?tab=analysis&from=2025-03-01&to=2025-03-31", MeterDrill.Href(9, isVirtual: true, year, shownYear, march, ResolutionClass.Month)); var month = year.WithCustomRange(D(2025, 3, 1), D(2025, 3, 31)); var onlyMarch = Buckets(D(2025, 3, 1), D(2025, 3, 31))[0]; Assert.Null(MeterDrill.Href(9, isVirtual: true, month, Range(D(2025, 3, 1), D(2025, 3, 31)), onlyMarch, ResolutionClass.Month)); // A physical meter's month opens its records, and a finer resolution still opens days. Assert.Equal( "/meters/4?tab=normalized&from=2025-03-01&to=2025-03-31", MeterDrill.Href(4, isVirtual: false, year, shownYear, march, ResolutionClass.Month)); Assert.Equal( "/meters/9?tab=analysis&from=2025-03-01&to=2025-03-31&bucket=day", MeterDrill.Href(9, isVirtual: true, year, shownYear, march, ResolutionClass.Day)); } [Fact] public void The_month_cut_at_now_opens_as_the_whole_month() { var period = PeriodResolver.Resolve(PeriodPreset.Last12Months, null, null, Now, Berlin); var current = BucketPlanner.Plan(period, BucketSize.Month).Buckets[^1]; Assert.True(current.IsCutShort); var drilled = AnalysisNavigation.DrillInto(History, current)!; Assert.Equal((D(2026, 9, 1), D(2026, 9, 30)), (drilled.From!.Value, drilled.To!.Value)); } [Fact] public void A_named_year_comparison_becomes_the_year_before_below_a_year() { var named = History.WithCustomRange(D(2025, 1, 1), D(2025, 12, 31)).WithComparison(new ComparisonRequest(ComparisonKind.Year, 2023)); var month = Buckets(D(2025, 3, 1), D(2025, 3, 31))[0]; var year = Buckets(D(2025, 1, 1), D(2025, 12, 31), BucketSize.Year)[0]; Assert.Equal(ComparisonKind.PreviousYear, AnalysisNavigation.DrillInto(named, month)!.Comparison.Kind); Assert.Equal(new ComparisonRequest(ComparisonKind.Year, 2023), AnalysisNavigation.DrillInto(named, year)!.Comparison); } [Theory] [InlineData(PeriodPreset.MonthToDate, "2023-03-01", "2023-03-31")] [InlineData(PeriodPreset.LastMonth, "2023-03-01", "2023-03-31")] [InlineData(PeriodPreset.YearToDate, "2023-01-01", "2023-12-31")] [InlineData(PeriodPreset.PreviousYear, "2023-01-01", "2023-12-31")] [InlineData(PeriodPreset.Last12Months, "2022-04-01", "2023-03-31")] [InlineData(PeriodPreset.Last24Months, "2021-04-01", "2023-03-31")] public void Latest_data_keeps_the_kind_of_period(PeriodPreset preset, string first, string last) { var availability = new AvailableRange(Now, Now, D(2020, 1, 1), D(2023, 3, 15)); var latest = AnalysisNavigation.LatestData(History.WithPeriod(preset), availability)!; Assert.True(latest.IsCustom); Assert.Equal(DateOnly.Parse(first, System.Globalization.CultureInfo.InvariantCulture), latest.From); Assert.Equal(DateOnly.Parse(last, System.Globalization.CultureInfo.InvariantCulture), latest.To); } [Fact] public void Latest_data_of_a_custom_range_keeps_its_length_and_needs_availability() { var availability = new AvailableRange(Now, Now, D(2020, 1, 1), D(2023, 3, 15)); var tenDays = History.WithCustomRange(D(2026, 9, 1), D(2026, 9, 10)); var latest = AnalysisNavigation.LatestData(tenDays, availability)!; Assert.Equal((D(2023, 3, 6), D(2023, 3, 15)), (latest.From!.Value, latest.To!.Value)); Assert.Null(AnalysisNavigation.LatestData(tenDays, null)); } [Fact] public void Breadcrumbs_carry_the_period_up_and_end_at_the_current_page() => In("en", () => { var ytd = AnalysisQuery.Default(AnalysisDefaults.History.ForScope(QueryScope.ForMeter(5))).WithPeriod(PeriodPreset.YearToDate); var meterPage = AnalysisNavigation.Breadcrumbs(ytd, (1, "Strom"), (5, "Auto")); Assert.Equal( [new Crumb("Overview", "/?period=ytd"), new Crumb("Strom", "/energy/1?period=ytd"), new Crumb("Auto", null)], meterPage); var below = AnalysisNavigation.Breadcrumbs(ytd, (1, "Strom"), (5, "Auto"), "Normalized data"); Assert.Equal("/meters/5?tab=analysis&period=ytd", below[2].Href); Assert.Equal(new Crumb("Normalized data", null), below[3]); // From the Overview's month to date: the Overview's own default is not written, a history page's is. var mtd = AnalysisQuery.Default(AnalysisDefaults.Overview); Assert.Equal([new Crumb("Overview", "/"), new Crumb("Strom", null)], AnalysisNavigation.Breadcrumbs(mtd, (1, "Strom"))); Assert.Equal("/energy/1?period=mtd", AnalysisNavigation.Breadcrumbs(mtd, (1, "Strom"), current: "Flow")[1].Href); Assert.Equal([new Crumb("Overview", null)], AnalysisNavigation.Breadcrumbs(null)); }); [Fact] public void A_formula_names_its_meters_beside_their_tokens() { var segments = FormulaText.Split("m5 + m6"); Assert.Equal([new FormulaSegment("m5", 5), new FormulaSegment(" + ", null), new FormulaSegment("m6", 6)], segments); var mixed = FormulaText.Split("0.5*m12 - (m3/m44) + mx + m1a + M2 + 1.3m7"); Assert.Equal([12, 3, 44, 7], mixed.Where(s => s.IsMeter).Select(s => s.MeterId!.Value)); Assert.Equal("0.5*m12 - (m3/m44) + mx + m1a + M2 + 1.3m7", string.Concat(mixed.Select(s => s.Text))); var names = new Dictionary { [5] = "Solar 1", [6] = "Solar 2" }; Assert.Equal("m5 (Solar 1) + m6 (Solar 2) - m9", FormulaText.Annotate("m5 + m6 - m9", id => names.GetValueOrDefault(id))); Assert.Empty(FormulaText.Split(null)); Assert.Empty(FormulaText.Split(" ")); } }