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