using MeterVault.App.Analysis; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; namespace MeterVault.Integration.Tests.Analysis; /// /// The analysis URL state (D-02, D-46, D-47, brief ยง4.1): parsing with page defaults and notices, canonical writing that /// omits defaults, resolving on a frozen clock, and the one mapping onto the readers' requests. Pure; no database. /// public sealed class AnalysisQueryTests { private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); /// 19 September 2026, 14:37 Berlin โ€” the frozen now of the cost tests. private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime(); [Fact] public void Absent_keys_take_the_page_defaults() { var overview = AnalysisQuery.Parse("/", AnalysisDefaults.Overview); Assert.Equal(PeriodPreset.MonthToDate, overview.Period); Assert.Equal(BucketSize.Auto, overview.Bucket); Assert.Equal(ComparisonKind.PreviousYear, overview.Comparison.Kind); Assert.Null(overview.Metric); Assert.Equal(QueryScope.Portfolio, overview.Scope); Assert.Empty(overview.Notices); var history = AnalysisQuery.Parse("http://localhost/meters/5?tab=readings&action=reading", AnalysisDefaults.History); Assert.Equal(PeriodPreset.Last12Months, history.Period); Assert.Equal(ComparisonKind.PreviousYear, history.Comparison.Kind); Assert.Equal(AnalysisQuery.Default(AnalysisDefaults.History), history); } [Fact] public void A_page_that_names_its_scope_by_route_reads_it_from_its_defaults() { var defaults = AnalysisDefaults.History.ForScope(QueryScope.ForMeter(42)); var query = AnalysisQuery.Parse("/meters/42?tab=analysis&period=ytd", defaults); Assert.Equal(QueryScope.ForMeter(42), query.Scope); Assert.Equal(PeriodPreset.YearToDate, query.Period); // ...and writing it back for that page never repeats the scope. Assert.Equal([new("period", "ytd")], query.ToQueryParameters(defaults)); } [Theory] [InlineData("?period=mtd&bucket=day&compare=none&metric=cost")] [InlineData("?period=all&bucket=year&compare=prev-period&metric=generation")] [InlineData("?from=2025-01-01&to=2025-12-31&bucket=month&compare=year:2023")] [InlineData("?scope=type&id=3&metric=consumption&period=24m")] [InlineData("?scope=category&id=7&metric=cost&period=last-month")] [InlineData("?scope=meters&ids=3,5,9&metric=export&bucket=week")] [InlineData("?scope=portfolio&period=prev-year")] public void Written_parameters_parse_back_to_the_same_query(string url) { var defaults = AnalysisDefaults.Overview; var query = AnalysisQuery.Parse(url, defaults); Assert.Empty(query.Notices); var written = query.AppendTo("/trends", defaults, AnalysisQueryParts.All); var again = AnalysisQuery.Parse(written, defaults); Assert.Equal(query, again); Assert.Equal(written, again.AppendTo("/trends", defaults, AnalysisQueryParts.All)); } [Fact] public void Keys_equal_to_the_target_default_are_left_out() { Assert.Empty(AnalysisQuery.Default(AnalysisDefaults.History).ToQueryParameters(AnalysisDefaults.History)); Assert.Empty(AnalysisQuery.Default(AnalysisDefaults.Overview).ToQueryParameters(AnalysisDefaults.Overview)); // The Overview's month to date is not a history page's default: carried onward, it is written. var fromOverview = AnalysisQuery.Default(AnalysisDefaults.Overview); Assert.Equal("/meters/5?tab=analysis&period=mtd", fromOverview.AppendTo("/meters/5?tab=analysis", AnalysisDefaults.History)); // A custom range is written as its dates alone; from/to imply custom. var custom = fromOverview.WithCustomRange(new DateOnly(2025, 1, 1), new DateOnly(2025, 12, 31)).WithBucket(BucketSize.Month); Assert.Equal("/trends?from=2025-01-01&to=2025-12-31&bucket=month", custom.AppendTo("/trends", AnalysisDefaults.History)); } [Fact] public void Links_carry_the_period_but_not_the_scope() { var query = AnalysisQuery.Parse("?scope=type&id=3&metric=cost&period=ytd&compare=none", AnalysisDefaults.History); Assert.Equal("/meters/9?metric=cost&period=ytd&compare=none", query.AppendTo("/meters/9", AnalysisDefaults.History)); Assert.Equal( "/trends?scope=type&id=3&metric=cost&period=ytd&compare=none", query.AppendTo("/trends", AnalysisDefaults.History, AnalysisQueryParts.All)); } [Fact] public void Navigation_parameters_remove_keys_that_return_to_the_default() { var custom = AnalysisQuery.Parse("?from=2025-01-01&to=2025-03-31", AnalysisDefaults.History); var backToDefault = custom.WithPeriod(PeriodPreset.Last12Months); var parameters = backToDefault.ToNavigationParameters(AnalysisDefaults.History); Assert.Equal(AnalysisUrlKeys.All.Order(), parameters.Keys.Order()); Assert.All(parameters.Values, Assert.Null); var ytd = custom.WithPeriod(PeriodPreset.YearToDate).ToNavigationParameters(AnalysisDefaults.History, AnalysisQueryParts.Carry); Assert.Equal("ytd", ytd[AnalysisUrlKeys.Period]); Assert.Null(ytd[AnalysisUrlKeys.From]); Assert.Null(ytd[AnalysisUrlKeys.To]); Assert.False(ytd.ContainsKey(AnalysisUrlKeys.Scope)); } [Theory] [InlineData("?compare=previous-year", ComparisonKind.PreviousYear, "prev-year")] [InlineData("?compare=previous-period", ComparisonKind.PreviousPeriod, "prev-period")] [InlineData("?COMPARE=Prev-Period", ComparisonKind.PreviousPeriod, "prev-period")] [InlineData("?compare=%20none%20", ComparisonKind.None, "none")] public void The_brief_s_spellings_are_read_and_the_canonical_tokens_written(string url, ComparisonKind kind, string token) { var query = AnalysisQuery.Parse(url, AnalysisDefaults.Overview); Assert.Equal(kind, query.Comparison.Kind); Assert.Empty(query.Notices); // Against a default of no comparison, every comparison is written โ€” in its canonical token. var none = new AnalysisDefaults(PeriodPreset.MonthToDate, BucketSize.Auto, ComparisonRequest.None); var written = query.ToQueryParameters(none, AnalysisQueryParts.Comparison); Assert.Equal(kind == ComparisonKind.None ? [] : [new KeyValuePair("compare", token)], written); } [Fact] public void A_named_year_keeps_its_colon() { var query = AnalysisQuery.Default(AnalysisDefaults.History).WithComparison(new ComparisonRequest(ComparisonKind.Year, 2024)); Assert.Equal("/solar?compare=year:2024", query.AppendTo("/solar", AnalysisDefaults.History)); Assert.Equal(new ComparisonRequest(ComparisonKind.Year, 2024), AnalysisQuery.Parse("/solar?compare=year%3A2024", AnalysisDefaults.History).Comparison); } [Theory] [InlineData("?period=forever", AnalysisQueryNoticeKind.InvalidPeriod, "period")] [InlineData("?bucket=hourly", AnalysisQueryNoticeKind.InvalidBucket, "bucket")] [InlineData("?compare=year:99", AnalysisQueryNoticeKind.InvalidComparison, "compare")] [InlineData("?compare=year:3000", AnalysisQueryNoticeKind.InvalidComparison, "compare")] [InlineData("?metric=happiness", AnalysisQueryNoticeKind.InvalidMetric, "metric")] [InlineData("?scope=galaxy", AnalysisQueryNoticeKind.InvalidScope, "scope")] [InlineData("?scope=meter", AnalysisQueryNoticeKind.InvalidScope, "scope")] [InlineData("?scope=meter&id=-4", AnalysisQueryNoticeKind.InvalidId, "id")] [InlineData("?scope=type&id=abc", AnalysisQueryNoticeKind.InvalidId, "id")] [InlineData("?from=2025-01-01", AnalysisQueryNoticeKind.InvalidRange, "from/to")] [InlineData("?period=custom", AnalysisQueryNoticeKind.InvalidRange, "from/to")] [InlineData("?from=2025-12-31&to=2025-01-01", AnalysisQueryNoticeKind.InvalidRange, "from/to")] [InlineData("?from=2025-02-29&to=2025-03-01", AnalysisQueryNoticeKind.InvalidRange, "from/to")] [InlineData("?from=1850-01-01&to=1850-12-31", AnalysisQueryNoticeKind.InvalidRange, "from/to")] public void An_invalid_token_falls_back_to_the_default_with_a_notice(string url, AnalysisQueryNoticeKind kind, string key) { var query = AnalysisQuery.Parse(url, AnalysisDefaults.History); var notice = Assert.Single(query.Notices); Assert.Equal(kind, notice.Kind); Assert.Equal(key, notice.Key); Assert.False(string.IsNullOrWhiteSpace(notice.Describe())); // Notices are not part of the value: the query is exactly the page default. Assert.Equal(AnalysisQuery.Default(AnalysisDefaults.History), query); } [Fact] public void Dates_beside_a_preset_are_ignored_and_blank_values_are_absent() { var query = AnalysisQuery.Parse("?period=ytd&from=2025-01-01&to=2025-02-01&bucket=&metric=%20", AnalysisDefaults.History); Assert.Equal(PeriodPreset.YearToDate, query.Period); Assert.Null(query.From); Assert.Null(query.To); Assert.Equal(BucketSize.Auto, query.Bucket); Assert.Empty(query.Notices); } [Fact] public void A_meter_selection_keeps_valid_distinct_ids_up_to_the_series_limit() { var mixed = AnalysisQuery.Parse("?scope=meters&ids=3,5,x,3,,0", AnalysisDefaults.History); Assert.Equal([3, 5], mixed.Scope.MeterIds); var invalid = Assert.Single(mixed.Notices); Assert.Equal(AnalysisQueryNoticeKind.InvalidId, invalid.Kind); Assert.Equal("x,0", invalid.Value); var many = AnalysisQuery.Parse("?scope=meters&ids=1,2,3,4,5,6,7,8", AnalysisDefaults.History); Assert.Equal(AnalysisLimits.MaxSeries, many.Scope.MeterIds.Count); Assert.Equal([1, 2, 3, 4, 5, 6], many.Scope.MeterIds); Assert.Equal(AnalysisQueryNoticeKind.TooManyMeters, Assert.Single(many.Notices).Kind); var repeated = AnalysisQuery.Parse("?scope=meters&ids=4&ids=2", AnalysisDefaults.History); Assert.Equal([4, 2], repeated.Scope.MeterIds); Assert.Equal("ids=4,2", string.Join('&', repeated.ToQueryParameters(AnalysisDefaults.History, AnalysisQueryParts.Scope).Skip(1).Select(p => p.Key + "=" + p.Value))); var none = AnalysisQuery.Parse("?scope=meters", AnalysisDefaults.History); Assert.Equal(QueryScope.Portfolio, none.Scope); Assert.Equal(AnalysisQueryNoticeKind.InvalidScope, Assert.Single(none.Notices).Kind); } [Fact] public void Ids_without_a_scope_mean_nothing() { var query = AnalysisQuery.Parse("?id=5&ids=1,2", AnalysisDefaults.History); Assert.Equal(QueryScope.Portfolio, query.Scope); Assert.Empty(query.Notices); } [Fact] public void The_helpers_refuse_what_no_url_could_hold() { var query = AnalysisQuery.Default(AnalysisDefaults.History); Assert.Throws(() => query.WithPeriod(PeriodPreset.Custom)); Assert.Throws(() => query.WithCustomRange(new DateOnly(2025, 2, 1), new DateOnly(2025, 1, 1))); Assert.Throws(() => query.WithComparison(new ComparisonRequest(ComparisonKind.Year))); Assert.Throws(() => new AnalysisDefaults(PeriodPreset.Custom, BucketSize.Auto, ComparisonRequest.None)); Assert.Throws(() => QueryScope.ForMeters([])); Assert.Throws(() => QueryScope.ForMeter(0)); } [Theory] [InlineData("/", true)] [InlineData("", true)] [InlineData("?period=ytd", true)] [InlineData("http://localhost:8760/", true)] [InlineData("http://localhost:8760/?period=ytd", true)] [InlineData("meters/5", false)] [InlineData("/trends", false)] [InlineData("http://localhost:8760/energy/2?tab=history", false)] public void Components_outside_a_page_find_its_defaults_by_path(string path, bool overview) => Assert.Same(overview ? AnalysisDefaults.Overview : AnalysisDefaults.History, AnalysisDefaults.ForPath(path)); [Fact] public void Resolving_uses_the_captured_now_in_the_instance_zone() { var twelve = AnalysisQuery.Default(AnalysisDefaults.History).Resolve(Now, Berlin); Assert.Equal(new DateOnly(2025, 10, 1), twelve.FirstDay); Assert.True(twelve.IsToDate); Assert.Equal(Now, twelve.To); var custom = AnalysisQuery.Parse("?from=2024-01-01&to=2024-12-31", AnalysisDefaults.History).Resolve(Now, Berlin); Assert.Equal(PeriodPreset.Custom, custom.Preset); Assert.Equal(new DateTimeOffset(2023, 12, 31, 23, 0, 0, TimeSpan.Zero), custom.From); Assert.Equal(new DateTimeOffset(2024, 12, 31, 23, 0, 0, TimeSpan.Zero), custom.To); } [Fact] public void All_history_spans_the_availability_it_is_given() { var all = AnalysisQuery.Parse("?period=all", AnalysisDefaults.History); var availability = AvailableRange.Of( new DateTimeOffset(2021, 3, 31, 22, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 5, 31, 22, 0, 0, TimeSpan.Zero), Berlin); var spanned = all.Resolve(Now, Berlin, availability); Assert.Equal(new DateOnly(2021, 4, 1), spanned.FirstDay); Assert.Equal(new DateOnly(2026, 5, 31), spanned.LastDay); // Without any data, "all" is the empty "no history" range โ€” never a century of zeros. Assert.True(all.Resolve(Now, Berlin).HasNoHistory()); } [Fact] public void Requests_are_built_in_one_place() { var query = AnalysisQuery.Parse("?scope=meters&ids=3,5&bucket=month&compare=prev-period&metric=cost", AnalysisDefaults.History); var period = query.Resolve(Now, Berlin); var quantity = query.ToAnalysisRequest(period)!; Assert.Equal(AnalysisScope.ForMeters([3, 5]), quantity.Scope); Assert.Equal(BucketSize.Month, quantity.Bucket); Assert.Equal(ComparisonKind.PreviousPeriod, quantity.Comparison.Kind); Assert.Same(period, quantity.Period); var costs = query.ToCostRequests(period); Assert.Equal([CostScope.ForMeter(3), CostScope.ForMeter(5)], costs.Select(c => c.Scope)); Assert.All(costs, c => Assert.Equal(BucketSize.Month, c.Bucket)); var portfolio = AnalysisQuery.Default(AnalysisDefaults.Overview).ToCostRequests(period, includeCategories: true); Assert.True(Assert.Single(portfolio).IncludeCategories); var category = AnalysisQuery.Parse("?scope=category&id=4", AnalysisDefaults.History); Assert.Null(category.ToAnalysisRequest(period)); Assert.Equal(CostScope.ForCategory(4), Assert.Single(category.ToCostRequests(period)).Scope); } [Fact] public void A_cost_comparison_is_priced_in_the_images_of_the_current_buckets() { var query = AnalysisQuery.Parse("?scope=type&id=2&bucket=month", AnalysisDefaults.History); var period = query.Resolve(Now, Berlin); var plan = BucketPlanner.Plan(period, BucketSize.Month); var current = Assert.Single(query.ToCostRequests(period, plan)); var comparison = query.ToCostComparison(current, plan); Assert.True(comparison.IsApplicable); Assert.Equal(12, comparison.Pairs.Count); var request = comparison.Request!; Assert.Equal(current.Scope, request.Scope); Assert.Equal(new DateOnly(2024, 10, 1), request.Period.FirstDay); Assert.Equal(12, request.Plan!.Buckets.Count); Assert.Equal(new DateOnly(2024, 10, 1), request.Plan.Buckets[0].FirstDay); Assert.Equal(new DateOnly(2025, 9, 1), request.Plan.Buckets[^1].FirstDay); var none = query.WithComparison(ComparisonRequest.None).ToCostComparison(current, plan); Assert.False(none.IsApplicable); Assert.Equal(ComparisonUnavailableReason.NotRequested, none.Resolution.Reason); } }