using MeterVault.App.Analysis; using MeterVault.App.AnalysisPage; using MeterVault.Core.Analysis; using MeterVault.Infrastructure.Analysis; namespace MeterVault.Integration.Tests.Analysis; /// /// The Analysis page's reading of its address (brief §7.4, D-47): URL → scope and metric, which metrics a scope supports, /// the fallback with a notice for a metric that does not apply, the explanation (never a silent cut or a silent switch) /// for more than six meters and for a category whose meters are not one kind in one unit, and the query the readers and /// the CSV export are asked with. Pure; no database. /// public sealed class AnalysisPageSelectionTests { // The seeded instance in miniature: Strom (use Haus, grid Netz, breakdown Auto, generation Solar 1 + 2, Summe Solar), // Wasser, Heizöl (tank + burner), and an empty Gas. private const int Strom = 1, Wasser = 2, Heizoel = 3, Gas = 4; private const int Haus = 1, Netz = 2, Auto = 3, Solar1 = 4, Solar2 = 5, WasserMeter = 6, Tank = 7, Burner = 8, SummeSolar = 9; private const int StromCategory = 2, WasserCategory = 3, HeizungCategory = 1, AllMeters = 5; private static readonly AnalysisPageOptions Options = new( [ new AnalysisPageType(Strom, "Strom", [AnalysisMetric.Consumption, AnalysisMetric.Generation]), new AnalysisPageType(Wasser, "Wasser", [AnalysisMetric.Consumption]), new AnalysisPageType(Heizoel, "Heizöl", [AnalysisMetric.Consumption, AnalysisMetric.Runtime]), new AnalysisPageType(Gas, "Gas", []), ], [ new AnalysisPageCategory(HeizungCategory, "Heizung", []), new AnalysisPageCategory(StromCategory, "Strom", [Haus, Netz, Auto, Solar1, Solar2]), new AnalysisPageCategory(WasserCategory, "Wasser", [WasserMeter]), new AnalysisPageCategory(4, "Haushalt", [Haus, WasserMeter]), new AnalysisPageCategory(AllMeters, "Viele", [Haus, Netz, Auto, 10, 11, 12, 13]), ], [ new AnalysisPageMeter(Haus, "Zähler Haus", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), new AnalysisPageMeter(Netz, "Zähler Netz", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), new AnalysisPageMeter(Auto, "Zähler Auto", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), new AnalysisPageMeter(Solar1, "Zähler Solar 1", Strom, false, QuantityKind.Generation, "kWh", IsCostable: false), new AnalysisPageMeter(Solar2, "Zähler Solar 2", Strom, false, QuantityKind.Generation, "kWh", IsCostable: false), new AnalysisPageMeter(WasserMeter, "Zähler Wasser", Wasser, false, QuantityKind.Consumption, "m3", IsCostable: true), new AnalysisPageMeter(Tank, "Öltank", Heizoel, false, QuantityKind.Consumption, "L", IsCostable: true), new AnalysisPageMeter(Burner, "Brenner", Heizoel, false, QuantityKind.Runtime, "h", IsCostable: false), new AnalysisPageMeter(SummeSolar, "Summe Solar", Strom, true, QuantityKind.Generation, "kWh", IsCostable: false), new AnalysisPageMeter(10, "Pumpe 1", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), new AnalysisPageMeter(11, "Pumpe 2", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), new AnalysisPageMeter(12, "Pumpe 3", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), new AnalysisPageMeter(13, "Pumpe 4", Strom, false, QuantityKind.Consumption, "kWh", IsCostable: true), ]); private static AnalysisSelection Select(string url) => AnalysisSelection.Resolve(AnalysisQuery.Parse(url, AnalysisDefaults.History), Options); [Fact] public void Without_keys_the_page_shows_the_portfolio_cost_of_the_last_12_months() { var query = AnalysisQuery.Parse("/trends", AnalysisDefaults.History); var selection = AnalysisSelection.Resolve(query, Options); Assert.Equal(QueryScope.Portfolio, selection.Scope); Assert.Equal(AnalysisMetric.Cost, selection.Metric); Assert.Equal(AnalysisMetric.Cost, selection.NaturalMetric); Assert.True(selection.IsCost); Assert.Equal([AnalysisMetric.Cost, AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Runtime], selection.Metrics); Assert.Equal(AnalysisPageRefusal.None, selection.Refusal); Assert.Empty(selection.Notices); Assert.Equal(PeriodPreset.Last12Months, query.Period); Assert.Equal(ComparisonKind.PreviousYear, query.Comparison.Kind); // Shown and written back, the defaults leave the address empty. Assert.Empty(selection.Shown(query).ToQueryParameters(AnalysisDefaults.History)); } [Fact] public void An_energy_type_offers_its_measures_and_its_cost_and_defaults_to_consumption() { var strom = Select($"/trends?scope=type&id={Strom}"); Assert.Equal(QueryScope.ForEnergyType(Strom), strom.Scope); Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Cost], strom.Metrics); Assert.Equal(AnalysisMetric.Consumption, strom.Metric); Assert.Equal("Strom", strom.ScopeName); Assert.False(strom.ShowsMeters); Assert.Equal(AnalysisMetric.Generation, Select($"/trends?scope=type&id={Strom}&metric=generation").Metric); Assert.True(Select($"/trends?scope=type&id={Strom}&metric=cost").IsCost); // A type without meters has only its cost. var gas = Select($"/trends?scope=type&id={Gas}"); Assert.Equal([AnalysisMetric.Cost], gas.Metrics); Assert.Equal(AnalysisMetric.Cost, gas.Metric); } [Fact] public void A_metric_the_scope_does_not_support_falls_back_to_its_natural_one_with_a_notice() { var water = Select($"/trends?scope=type&id={Wasser}&metric=generation"); Assert.Equal(AnalysisMetric.Consumption, water.Metric); var notice = Assert.Single(water.Notices); Assert.Equal(AnalysisPageNoticeKind.MetricNotAvailable, notice.Kind); Assert.Equal(AnalysisMetric.Generation, notice.Requested); Assert.Equal(AnalysisMetric.Consumption, notice.Shown); // Generation is never billed (D-34): a solar meter has no cost to show. var solar = Select($"/trends?scope=meter&id={Solar1}&metric=cost"); Assert.Equal(AnalysisMetric.Generation, solar.Metric); Assert.Equal([AnalysisMetric.Generation], solar.Metrics); Assert.Single(solar.Notices); // An unknown token is the query's notice (D-02), and the natural metric is shown without a second one. var query = AnalysisQuery.Parse($"/trends?scope=meter&id={Haus}&metric=wattage", AnalysisDefaults.History); Assert.Equal(AnalysisQueryNoticeKind.InvalidMetric, Assert.Single(query.Notices).Kind); var haus = AnalysisSelection.Resolve(query, Options); Assert.Equal(AnalysisMetric.Consumption, haus.Metric); Assert.Empty(haus.Notices); } [Fact] public void A_meter_shows_its_own_quantity_and_its_cost_when_it_has_one() { var haus = Select($"/trends?scope=meter&id={Haus}"); Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Cost], haus.Metrics); Assert.Equal(AnalysisMetric.Consumption, haus.Metric); Assert.Equal([Haus], haus.SeriesMeterIds); Assert.Equal(Strom, haus.EnergyTypeId); Assert.True(haus.ShowsMeters); // A calculated meter is a meter like any other here: its generation, no cost (analysis only). var summe = Select($"/trends?scope=meter&id={SummeSolar}"); Assert.Equal([AnalysisMetric.Generation], summe.Metrics); Assert.Equal("Summe Solar", summe.ScopeName); Assert.Equal(AnalysisPageRefusal.UnknownScope, Select("/trends?scope=meter&id=999").Refusal); Assert.Equal(AnalysisPageRefusal.UnknownScope, Select("/trends?scope=type&id=999").Refusal); Assert.Equal(AnalysisPageRefusal.UnknownScope, Select("/trends?scope=category&id=999").Refusal); } [Fact] public void A_comparison_compares_the_meters_of_the_chosen_metric_and_names_the_others() { var consumption = Select($"/trends?scope=meters&ids={Haus},{Solar1},{Netz},{SummeSolar}"); Assert.Equal(AnalysisMetric.Consumption, consumption.Metric); Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Cost], consumption.Metrics); Assert.Equal([Haus, Netz], consumption.SeriesMeterIds); Assert.Equal([Solar1, SummeSolar], consumption.HiddenMeterIds); var generation = Select($"/trends?scope=meters&ids={Haus},{Solar1},{Netz},{SummeSolar}&metric=generation"); Assert.Equal([Solar1, SummeSolar], generation.SeriesMeterIds); Assert.Equal([Haus, Netz], generation.HiddenMeterIds); // Its cost prices only the meters that can have one. var cost = Select($"/trends?scope=meters&ids={Haus},{Solar1},{WasserMeter}&metric=cost"); Assert.Equal([Haus, WasserMeter], cost.SeriesMeterIds); Assert.Equal([Solar1], cost.HiddenMeterIds); // The readers are asked for exactly the meters shown; the address keeps the whole selection. var query = AnalysisQuery.Parse($"/trends?scope=meters&ids={Haus},{Solar1},{Netz}", AnalysisDefaults.History); var selection = AnalysisSelection.Resolve(query, Options); Assert.Equal(QueryScope.ForMeters([Haus, Netz]), selection.ReadQuery(query).Scope); Assert.Equal(AnalysisMetric.Consumption, selection.ReadQuery(query).Metric); Assert.Equal(QueryScope.ForMeters([Haus, Solar1, Netz]), selection.Shown(query).Scope); } [Fact] public void More_than_six_meters_are_refused_never_cut_silently() { // Built directly (a link written by hand), seven meters are refused with the reason. var seven = QueryScope.ForMeters([Haus, Netz, Auto, 10, 11, 12, 13]); var selection = AnalysisSelection.Resolve(AnalysisQuery.Default(AnalysisDefaults.History).WithScope(seven), Options); Assert.Equal(AnalysisPageRefusal.TooManyMeters, selection.Refusal); Assert.Empty(selection.SeriesMeterIds); Assert.Equal(7, selection.Scope.MeterIds.Count); // From an address, the query keeps the first six and says so (the notice the page shows). var parsed = AnalysisQuery.Parse("/trends?scope=meters&ids=1,2,3,10,11,12,13", AnalysisDefaults.History); Assert.Equal(AnalysisQueryNoticeKind.TooManyMeters, Assert.Single(parsed.Notices).Kind); Assert.Equal(AnalysisLimits.MaxSeries, parsed.Scope.MeterIds.Count); Assert.Equal(AnalysisPageRefusal.None, AnalysisSelection.Resolve(parsed, Options).Refusal); // Exactly six is fine. var six = AnalysisSelection.Resolve( AnalysisQuery.Default(AnalysisDefaults.History).WithScope(QueryScope.ForMeters([Haus, Netz, Auto, 10, 11, 12])), Options); Assert.Equal(AnalysisPageRefusal.None, six.Refusal); Assert.Equal(6, six.SeriesMeterIds.Count); } [Fact] public void Meters_that_no_longer_exist_are_left_out_with_a_notice() { var selection = Select($"/trends?scope=meters&ids={Haus},998,{Netz}"); Assert.Equal(QueryScope.ForMeters([Haus, Netz]), selection.Scope); Assert.Equal(AnalysisPageNoticeKind.UnknownMetersLeftOut, Assert.Single(selection.Notices).Kind); Assert.Equal(AnalysisPageRefusal.UnknownScope, Select("/trends?scope=meters&ids=998,999").Refusal); } [Fact] public void A_category_is_analysed_by_cost_and_by_a_quantity_only_when_its_meters_are_one_kind_in_one_unit() { var water = Select($"/trends?scope=category&id={WasserCategory}"); Assert.Equal(AnalysisMetric.Cost, water.Metric); Assert.Equal([AnalysisMetric.Cost, AnalysisMetric.Consumption], water.Metrics); var waterQuantity = Select($"/trends?scope=category&id={WasserCategory}&metric=consumption"); Assert.Equal(AnalysisPageRefusal.None, waterQuantity.Refusal); Assert.Equal(AnalysisMetric.Consumption, waterQuantity.Metric); Assert.Equal([WasserMeter], waterQuantity.SeriesMeterIds); Assert.True(waterQuantity.ShowsMeters); // The readers read its meters (the quantity reader knows no categories); the CSV export follows. var query = AnalysisQuery.Parse($"/trends?scope=category&id={WasserCategory}&metric=consumption", AnalysisDefaults.History); Assert.Equal(QueryScope.ForMeters([WasserMeter]), AnalysisSelection.Resolve(query, Options).ReadQuery(query).Scope); // Strom's meters measure consumption and generation: its cost only. var strom = Select($"/trends?scope=category&id={StromCategory}"); Assert.Equal([AnalysisMetric.Cost], strom.Metrics); } [Fact] public void A_category_quantity_with_mixed_kinds_or_units_is_explained_not_shown_as_cost() { // kWh and m³ in one category. var mixedUnits = Select("/trends?scope=category&id=4&metric=consumption"); Assert.Equal(AnalysisPageRefusal.CategoryMixed, mixedUnits.Refusal); Assert.Equal(AnalysisMetric.Consumption, mixedUnits.Metric); Assert.Empty(mixedUnits.SeriesMeterIds); Assert.Equal(2, mixedUnits.Groups.Count); Assert.Contains(mixedUnits.Groups, g => g.Unit == "kWh" && g.MeterIds.SequenceEqual([Haus])); Assert.Contains(mixedUnits.Groups, g => g.Unit == "m³" && g.MeterIds.SequenceEqual([WasserMeter])); // Consumption and generation in one unit: still not one quantity. var mixedKinds = Select($"/trends?scope=category&id={StromCategory}&metric=consumption"); Assert.Equal(AnalysisPageRefusal.CategoryMixed, mixedKinds.Refusal); var consumption = mixedKinds.Groups.Single(g => g.Kind == QuantityKind.Consumption); Assert.Equal([Haus, Netz, Auto], consumption.MeterIds); Assert.Equal(AnalysisMetric.Consumption, consumption.Metric); Assert.Equal([Solar1, Solar2], mixedKinds.Groups.Single(g => g.Kind == QuantityKind.Generation).MeterIds); // Only manual costs, no meters. Assert.Equal(AnalysisPageRefusal.CategoryWithoutMeters, Select($"/trends?scope=category&id={HeizungCategory}&metric=consumption").Refusal); // One kind, one unit, but more meters than a chart holds side by side. Assert.Equal(AnalysisPageRefusal.CategoryTooManyMeters, Select($"/trends?scope=category&id={AllMeters}&metric=consumption").Refusal); // A metric the category's meters do not measure falls back to its cost with a notice. var waterGeneration = Select($"/trends?scope=category&id={WasserCategory}&metric=generation"); Assert.Equal(AnalysisPageRefusal.None, waterGeneration.Refusal); Assert.True(waterGeneration.IsCost); Assert.Equal(AnalysisPageNoticeKind.MetricNotAvailable, Assert.Single(waterGeneration.Notices).Kind); } [Fact] public void The_shown_state_writes_only_what_differs_from_the_scope_s_natural_metric() { var query = AnalysisQuery.Parse($"/trends?scope=type&id={Strom}&metric=consumption&period=prev-year&compare=year:2024", AnalysisDefaults.History); var shown = AnalysisSelection.Resolve(query, Options).Shown(query); Assert.Null(shown.Metric); Assert.Equal( [new("scope", "type"), new("id", Strom.ToString(System.Globalization.CultureInfo.InvariantCulture)), new("period", "prev-year"), new("compare", "year:2024")], shown.ToQueryParameters(AnalysisDefaults.History)); // A metric that fell back is not written back. var fallback = AnalysisQuery.Parse($"/trends?scope=type&id={Wasser}&metric=generation", AnalysisDefaults.History); Assert.Null(AnalysisSelection.Resolve(fallback, Options).Shown(fallback).Metric); } [Fact] public void Options_list_meters_grouped_by_energy_type_in_the_types_order() { var types = Options.Meters.Select(m => m.EnergyTypeId).ToList(); Assert.Equal([Strom, Strom, Strom, Strom, Strom, Strom, Strom, Strom, Strom, Strom, Wasser, Heizoel, Heizoel], types); // Within a type by name: Brenner before Öltank, Pumpe 1 … 4 in order. Assert.Equal([Burner, Tank], Options.Meters.Where(m => m.EnergyTypeId == Heizoel).Select(m => m.Id)); Assert.Equal([10, 11, 12, 13], Options.Meters.Where(m => m.Name.StartsWith("Pumpe", StringComparison.Ordinal)).Select(m => m.Id)); } }