Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s

The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
Florian Schmidt
2026-09-20 10:29:13 +02:00
parent c0f52dbb6f
commit 8940ef25c3
384 changed files with 82753 additions and 4518 deletions
@@ -0,0 +1,32 @@
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The analysis catalog built from loaded rows, without a database: one meter's broken configuration stays that meter's
/// finding and never fails the catalog every analysis, cost, flow and solar read is built on.
/// </summary>
public sealed class AnalysisCatalogTests
{
[Fact]
public void A_legacy_sum_of_hundreds_of_links_needs_configuration_and_the_other_meters_still_read()
{
// Review virtual F3: an expression-less virtual meter fed by 300 links implies a formula longer than 2,000
// characters. Deriving it used to throw out of Build, which every read calls.
var meters = Enumerable.Range(1000, 300)
.Select(id => new Meter { Id = id, Name = $"PV {id}", EnergyTypeId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh", Meta = "{}" })
.Append(new Meter { Id = 5000, Name = "Sum", EnergyTypeId = 1, Mode = MeterMode.Virtual, Unit = "kWh", Meta = "{}" })
.Append(new Meter { Id = 6000, Name = "Unrelated", EnergyTypeId = 2, Mode = MeterMode.CumulativeCounter, Unit = "kWh", Meta = "{}" })
.ToList();
var links = Enumerable.Range(1000, 300).Select(id => new MeterLink { FromMeterId = id, ToMeterId = 5000 }).ToList();
var catalog = AnalysisCatalog.Build(meters, [], links, [], TimeZoneInfo.Utc);
Assert.Equal(VirtualMeterStatus.NeedsConfiguration, catalog.Find(5000)!.VirtualStatus);
Assert.Equal(LegacyDerivationOutcome.Invalid, catalog.Find(5000)!.Legacy!.Outcome);
Assert.NotNull(catalog.Find(6000));
Assert.NotNull(catalog.Find(1000));
}
}
@@ -0,0 +1,318 @@
using System.Globalization;
using ApexCharts;
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The analysis chart's plan and options (D-49, brief §8), without the chart library's browser half: unknown buckets
/// stay gaps and a true zero stays a point, qualified buckets are marked in words and shape, overlays pair with their
/// buckets by index (A-10), labels carry the year across years, units never share an axis, the baseline is a real zero,
/// and the theme reaches the options. Pure; no database.
/// </summary>
public sealed class AnalysisChartModelTests
{
private static readonly ChartPalette Dark = ChartPalette.For(isDark: true);
[Fact]
public void Unknown_buckets_stay_gaps_and_a_true_zero_stays_a_point() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = new AnalysisChartSeries("m1", "Haus", "kWh", [Available(100), Missing(), Available(0)]);
var plan = AnalysisChartPlan.Build(buckets, [series], Dark);
var points = plan.Panels.Single().Series.Single().Points;
Assert.Equal([100m, null, 0m], points.Select(p => p.Value));
Assert.Equal([false, true, false], points.Select(p => p.IsQualified));
Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.GapMarker, "Mar"], plan.Panels[0].Labels);
Assert.Equal(["Jan", "Feb", "Mar"], plan.Labels);
Assert.Equal("—", points[1].Tooltip.Split(" · ")[0]);
Assert.Contains("No data", points[1].Tooltip, StringComparison.Ordinal);
Assert.Equal("0 kWh", points[2].Tooltip);
Assert.True(plan.HasValues);
});
[Fact]
public void A_series_shorter_than_the_plan_is_unknown_at_the_end_never_zero()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", [Available(5)])], Dark);
Assert.Equal([5m, null, null], plan.Panels[0].Series[0].Points.Select(p => p.Value));
Assert.Equal([false, true, true], plan.Marked);
}
[Fact]
public void A_true_zero_bar_is_drawn_on_the_baseline_and_a_bucket_without_value_is_marked_as_such() => In("en", () =>
{
// Brief §4.3 / DoD: a valid zero is an actual chart point, and it must not look like a month without data. A bar
// series draws an outline, so a zero is a line on the baseline; a gap has no bar and its own mark and note.
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Auto", "kWh", [Available(0), Missing(), Available(0)])], Dark);
var panel = plan.Panels.Single();
Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.GapMarker, "Mar"], panel.Labels);
Assert.False(panel.HasMarked);
Assert.True(panel.HasGaps);
Assert.True(plan.HasValues);
Assert.Equal([0m, null, 0m], panel.Series[0].Points.Select(p => p.Value));
Assert.True(panel.Series[0].StrokeWidth >= 1);
// A partial month keeps the "*" of a qualified value; both marks can meet in one bucket of two series.
var both = AnalysisChartPlan.Build(
buckets,
[
new AnalysisChartSeries("m1", "Auto", "kWh", [Available(1), Partial(2), Available(3)]),
new AnalysisChartSeries("m2", "Haus", "kWh", [Available(1), Missing(), Available(3)]),
],
Dark);
Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.Marker + AnalysisChartPlan.GapMarker, "Mar"], both.Panels[0].Labels);
Assert.True(both.Panels[0].HasMarked);
Assert.True(both.Panels[0].HasGaps);
});
[Fact]
public void A_plan_without_values_says_why_coarser_data_or_no_price_rather_than_no_data() => In("en", () =>
{
var buckets = Buckets(D(2026, 5, 1), D(2026, 5, 3), BucketSize.Day);
var unresolved = new BucketValue(null, BucketStatus.Unresolved, Provenance.Measured, ValueIssue.CoarseResolution);
var coarse = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Wasser", "m³", [unresolved, unresolved, unresolved])], Dark);
Assert.False(coarse.HasValues);
Assert.Equal(ChartEmptyReason.Unresolved, coarse.EmptyReason);
var nothing = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Wasser", "m³", [Missing(), Missing(), Missing()])], Dark);
Assert.Equal(ChartEmptyReason.NoData, nothing.EmptyReason);
// Valid quantities without any tariff: the cost is unavailable, and the chart says so in the cost card's words.
var months = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var none = Priced(months, 100, null).Lines[0].Buckets;
var unpriced = AnalysisChartPlan.Build(months, [AnalysisChartSeries.ForCost("c", "Heizöl", "EUR", none)], Dark);
Assert.False(unpriced.HasValues);
Assert.Equal(ChartEmptyReason.NotPriced, unpriced.EmptyReason);
Assert.Equal("Not priced (no tariff)", unpriced.EmptyStatus);
Assert.Equal(ChartEmptyReason.None, AnalysisChartPlan.Build(months, [new AnalysisChartSeries("m1", "Haus", "kWh", [Available(0), Missing(), Missing()])], Dark).EmptyReason);
});
[Fact]
public void Nothing_known_draws_nothing()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", [Missing(), Missing()])], Dark);
Assert.False(plan.HasValues);
}
[Fact]
public void Partial_and_estimated_buckets_are_marked_in_words_and_faded_not_by_colour_alone() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var values = new[] { Available(10), Partial(4), Available(8, Provenance.Measured | Provenance.Estimated) };
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", values)], Dark);
var series = plan.Panels[0].Series[0];
var colour = Dark.SeriesColor(0);
Assert.Equal(colour, series.Color);
Assert.Equal(colour, series.Points[0].FillColor);
Assert.Equal(ChartPalette.WithAlpha(colour, AnalysisChartPlan.QualifiedAlpha), series.Points[1].FillColor);
Assert.StartsWith("rgba(", series.Points[2].FillColor, StringComparison.Ordinal);
// The words travel with the point, and the label says "look here" without colour.
Assert.Equal("4.0 kWh · Partial · Measured — Data covers only part of this period", series.Points[1].Tooltip);
Assert.Contains("Estimated", series.Points[2].Tooltip, StringComparison.Ordinal);
Assert.Equal(["Jan", "Feb *", "Mar *"], plan.Panels[0].Labels);
Assert.True(plan.Panels[0].HasMarked);
});
[Fact]
public void A_comparison_overlay_pairs_with_its_buckets_by_index_and_names_its_own() => In("en", () =>
{
var period = Range(D(2026, 1, 1), D(2026, 3, 31));
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear));
var pairs = ComparisonResolver.PairBuckets(period, resolution.Period!, buckets);
var reader = Series(
1, "Haus", [Available(120), Available(100), Available(90)],
comparison: Comparison([Available(100), Missing(), Available(90)], Available(190), Change.Unavailable));
var current = AnalysisChartSeries.ForSeries(reader);
var overlay = AnalysisChartSeries.ComparisonOf(reader, AnalysisChartSeries.ComparisonName("Haus", new ComparisonRequest(ComparisonKind.PreviousYear)))!;
var plan = AnalysisChartPlan.Build(buckets, [current, overlay], Dark, pairs);
var drawn = plan.Panels.Single().Series;
Assert.Equal(2, drawn.Count);
var line = drawn[1];
Assert.True(line.IsComparison);
Assert.Equal(ChartSeriesStyle.Line, line.Style);
Assert.Equal(drawn[0].Color, line.Color);
Assert.Equal(5, line.DashSpace);
Assert.Equal("Haus (Same period last year)", line.Name);
// Point i of the overlay is the image of bucket i: January 2025 beside January 2026, a gap where 2025 had none.
Assert.Equal([100m, null, 90m], line.Points.Select(p => p.Value));
Assert.Equal("Jan 2025: 100 kWh", line.Points[0].Tooltip);
Assert.StartsWith("Feb 2025: —", line.Points[1].Tooltip, StringComparison.Ordinal);
// An overlay's gaps do not mark the current buckets.
Assert.Equal(["Jan", "Feb", "Mar"], plan.Panels[0].Labels);
Assert.False(plan.Panels[0].HasMarked);
});
[Fact]
public void Labels_carry_the_year_across_years_and_stay_distinct() => In("en", () =>
{
Assert.Equal(["Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026"], AnalysisChartPlan.BucketLabels(Buckets(D(2025, 11, 1), D(2026, 2, 28))));
Assert.Equal(["Oct", "Nov", "Dec"], AnalysisChartPlan.BucketLabels(Buckets(D(2025, 10, 1), D(2025, 12, 31))));
Assert.Equal(["2024", "2025"], AnalysisChartPlan.BucketLabels(Buckets(D(2024, 1, 1), D(2025, 12, 31), BucketSize.Year)));
var days = AnalysisChartPlan.BucketLabels(Buckets(D(2025, 12, 30), D(2026, 1, 2), BucketSize.Day));
Assert.Equal(["Dec 30, 2025", "Dec 31, 2025", "Jan 1, 2026", "Jan 2, 2026"], days);
});
[Fact]
public void Series_of_different_units_never_share_an_axis()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var costs = Priced(buckets, 100, 0.3).Lines[0].Buckets;
var plan = AnalysisChartPlan.Build(
buckets,
[
new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Available(2)]),
new AnalysisChartSeries("m2", "Wasser", "m³", [Available(3), Available(4)]),
new AnalysisChartSeries("m3", "Auto", "kWh", [Available(5), Available(6)]),
AnalysisChartSeries.ForCost("cost", "Cost", "EUR", costs),
],
Dark);
Assert.Equal(["kWh", "m³", "€"], plan.Panels.Select(p => p.Unit));
Assert.Equal(["Haus", "Auto"], plan.Panels[0].Series.Select(s => s.Name));
// Colour follows the series in the order given, across panels.
Assert.Equal([Dark.SeriesColor(0), Dark.SeriesColor(2)], plan.Panels[0].Series.Select(s => s.Color));
Assert.Equal(Dark.SeriesColor(1), plan.Panels[1].Series[0].Color);
}
[Fact]
public void Two_meters_with_one_name_stay_two_series()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
var plan = AnalysisChartPlan.Build(
buckets,
[new AnalysisChartSeries("m1", "Keller", "kWh", [Available(1)]), new AnalysisChartSeries("m2", "Keller", "kWh", [Available(2)])],
Dark);
Assert.Equal(["Keller", "Keller (2)"], plan.Panels[0].Series.Select(s => s.Name));
}
[Fact]
public void Costs_without_a_price_are_gaps_with_the_reason() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var gap = Priced(buckets, 100, 0.25, priceFrom: D(2026, 2, 1)).Lines[0].Buckets;
var none = Priced(buckets, 100, null).Lines[0].Buckets;
var plan = AnalysisChartPlan.Build(
buckets,
[AnalysisChartSeries.ForCost("a", "Strom", "EUR", gap), AnalysisChartSeries.ForCost("b", "Wasser", "EUR", none)],
Dark);
var withGap = plan.Panels[0].Series[0].Points;
Assert.Equal([null, 25m, 25m], withGap.Select(p => p.Value));
Assert.Equal("— · Unavailable (tariff gap)", withGap[0].Tooltip);
Assert.Equal("25.00 €", withGap[1].Tooltip);
Assert.All(plan.Panels[0].Series[1].Points, p => Assert.Null(p.Value));
Assert.Contains("Not priced (no tariff)", plan.Panels[0].Series[1].Points[0].Tooltip, StringComparison.Ordinal);
});
[Fact]
public void The_baseline_is_a_real_zero_and_signed_values_get_a_zero_line()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var signed = Options(buckets, [Available(-50), Available(30)]);
Assert.Null(signed.Yaxis[0].Min);
Assert.Null(signed.Yaxis[0].Max);
Assert.Equal(0, Assert.Single(signed.Annotations.Yaxis).Y);
var positive = Options(buckets, [Available(20), Available(30)]);
Assert.Equal(0, positive.Yaxis[0].Min);
Assert.Null(positive.Annotations);
var negative = Options(buckets, [Available(-20), Available(-30)]);
Assert.Equal(0, negative.Yaxis[0].Max);
}
[Fact]
public void Options_follow_the_theme_draw_straight_lines_and_do_not_animate()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Partial(2), Available(3)]) { Style = ChartSeriesStyle.Line };
var light = ChartPalette.For(isDark: false);
foreach (var palette in new[] { Dark, light })
{
var plan = AnalysisChartPlan.Build(buckets, [series], palette);
var options = AnalysisChartOptions.Build(plan.Panels[0], palette, CultureInfo.GetCultureInfo("de-DE"));
Assert.Equal("transparent", options.Chart.Background);
Assert.Equal(palette.IsDark ? Mode.Dark : Mode.Light, options.Theme.Mode);
Assert.Equal(palette.Text, options.Chart.ForeColor);
Assert.Equal(Curve.Straight, options.Stroke.Curve.Single());
Assert.False(options.Chart.Animations.Enabled);
Assert.Contains("\"de-DE\"", options.Yaxis[0].Labels.Formatter, StringComparison.Ordinal);
Assert.Contains("\" kWh\"", options.Yaxis[0].Labels.Formatter, StringComparison.Ordinal);
Assert.Equal(ChartFormatters.Tooltip, options.Tooltip.Y.Formatter);
// The partial point of the line is a hollow square: its shape marks it.
var marker = Assert.Single(options.Markers.Discrete);
Assert.Equal(1, marker.DataPointIndex);
Assert.Equal(MarkerShape.Square, marker.Shape);
}
Assert.NotEqual(Dark.Series[2], light.Series[2]);
}
[Fact]
public void Formatter_strings_cannot_be_broken_out_of()
{
var formatter = ChartFormatters.Axis("m\"3</script>", CultureInfo.GetCultureInfo("en-US"));
Assert.DoesNotContain("m\"3", formatter, StringComparison.Ordinal);
Assert.DoesNotContain("</script>", formatter, StringComparison.Ordinal);
Assert.Contains("\"en-US\"", formatter, StringComparison.Ordinal);
Assert.Equal("\"\\u20AC\"", ChartFormatters.Literal("€"));
Assert.StartsWith("function (value, opts)", ChartFormatters.Tooltip, StringComparison.Ordinal);
Assert.Contains("extra.text", ChartFormatters.Tooltip, StringComparison.Ordinal);
}
[Fact]
public void The_palette_comes_from_the_theme_as_plain_colours()
{
foreach (var palette in new[] { ChartPalette.For(true), ChartPalette.For(false) })
{
Assert.Equal(6, palette.Series.Count);
Assert.All(palette.Series, c => Assert.Matches("^#[0-9A-F]{6}$", c));
Assert.Equal(palette.Series[0], palette.SeriesColor(6));
Assert.StartsWith("rgba(", palette.Text, StringComparison.Ordinal);
}
Assert.Equal("#14B8A6", ChartPalette.For(true).Series[0]);
Assert.Equal("rgba(20,184,166,0.45)", ChartPalette.WithAlpha("#14B8A6", 0.45));
Assert.Equal("rgba(1,2,3,0.5)", ChartPalette.WithAlpha("rgba(1,2,3,0.5)", 0.2));
}
private static ApexChartOptions<ChartPoint> Options(IReadOnlyList<AnalysisBucket> buckets, BucketValue[] values)
{
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Netz", "kWh", values)], Dark);
return AnalysisChartOptions.Build(plan.Panels[0], Dark, CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,348 @@
using System.Net;
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.App.Components.Shared.Analysis;
using MeterVault.App.Theme;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Options;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using MudBlazor.Services;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The shared analysis components rendered to HTML with the framework's static <see cref="HtmlRenderer"/> (no bUnit, no
/// browser): they render with real MudBlazor services, the title is the page's h1, the toolbar shows the effective
/// dates, the table says "—" and speaks statuses, the empty and error states offer their actions, attention items link
/// to their fix. Static rendering proves the markup, not the interactive chart or navigation — those are covered by the
/// pure model tests and the manual checklist.
/// </summary>
public sealed class AnalysisComponentRenderTests
{
[Fact]
public async Task The_page_header_is_the_pages_h1_with_breadcrumbs_carrying_the_period()
{
var ytd = AnalysisQuery.Default(AnalysisDefaults.History.ForScope(QueryScope.ForMeter(5))).WithPeriod(PeriodPreset.YearToDate);
RenderFragment crumbs = builder =>
{
builder.OpenComponent<AnalysisBreadcrumbs>(0);
builder.AddComponentParameter(1, nameof(AnalysisBreadcrumbs.Query), ytd);
builder.AddComponentParameter(2, nameof(AnalysisBreadcrumbs.EnergyTypeId), 1);
builder.AddComponentParameter(3, nameof(AnalysisBreadcrumbs.EnergyTypeName), "Strom");
builder.AddComponentParameter(4, nameof(AnalysisBreadcrumbs.MeterId), 5);
builder.AddComponentParameter(5, nameof(AnalysisBreadcrumbs.MeterName), "Wärmepumpe <Keller>");
builder.CloseComponent();
};
var raw = await RenderRawAsync<PageHeader>("en", new()
{
[nameof(PageHeader.Title)] = "Wärmepumpe <Keller>",
[nameof(PageHeader.Description)] = "Heat pump",
[nameof(PageHeader.Breadcrumbs)] = crumbs,
});
var html = WebUtility.HtmlDecode(raw);
// User data is text, never markup; the title is the page's one h1.
Assert.Contains("&lt;Keller&gt;</h1>", raw, StringComparison.Ordinal);
Assert.Contains("<h1 class=\"mud-typography mud-typography-h4 mv-page-header__h1\">Wärmepumpe <Keller></h1>", html, StringComparison.Ordinal);
Assert.Contains("href=\"/?period=ytd\"", html, StringComparison.Ordinal);
Assert.Contains("href=\"/energy/1?period=ytd\"", html, StringComparison.Ordinal);
Assert.Contains("<nav aria-label=\"Breadcrumbs\"", html, StringComparison.Ordinal);
Assert.Contains("<span aria-current=\"page\">Wärmepumpe <Keller></span>", html, StringComparison.Ordinal);
Assert.Single(System.Text.RegularExpressions.Regex.Matches(html, "<h1"));
}
[Fact]
public async Task The_toolbar_shows_the_preset_with_its_effective_dates_and_a_refused_bucket()
{
var query = AnalysisQuery.Default(AnalysisDefaults.History).WithBucket(BucketSize.Day);
var period = query.WithPeriod(PeriodPreset.Last24Months).Resolve(Now, Berlin);
var plan = BucketPlanner.Plan(period, BucketSize.Day, maxPoints: AnalysisLimits.MaxPoints);
Assert.True(plan.Refused);
var html = await RenderAsync<PeriodToolbar>("de", new()
{
[nameof(PeriodToolbar.Query)] = query.WithPeriod(PeriodPreset.Last24Months),
[nameof(PeriodToolbar.Period)] = period,
[nameof(PeriodToolbar.Defaults)] = AnalysisDefaults.History,
[nameof(PeriodToolbar.Plan)] = plan,
[nameof(PeriodToolbar.ExportHref)] = "/export/analysis.csv?period=24m",
});
Assert.Contains("Letzte 24 Monate", html, StringComparison.Ordinal);
Assert.Contains(In("de", () => Format.PeriodRange(period)), html, StringComparison.Ordinal);
Assert.Contains("Das Intervall „Täglich“ bräuchte", html, StringComparison.Ordinal);
Assert.Contains("„Wöchentlich“ verwenden", html, StringComparison.Ordinal);
Assert.Contains("CSV exportieren", html, StringComparison.Ordinal);
Assert.Contains("Zurücksetzen", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_toolbar_shows_notices_for_what_the_address_got_wrong()
{
var query = AnalysisQuery.Parse("?period=fortnight&bucket=hourly", AnalysisDefaults.History);
var html = await RenderAsync<PeriodToolbar>("en", new() { [nameof(PeriodToolbar.Query)] = query });
Assert.Equal(2, query.Notices.Count);
foreach (var notice in query.Notices)
{
Assert.Contains(In("en", () => MeterVault.App.Localization.DisplayNames.Display(notice.Kind)), html, StringComparison.Ordinal);
}
Assert.DoesNotContain("fortnight", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_table_says_unknown_and_speaks_the_status()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = In("en", () => AnalysisTableSeries.ForSeries(Series(1, "Haus", [Available(120), Missing(), Available(0)])));
var html = await RenderAsync<AnalysisTable>("en", new()
{
[nameof(AnalysisTable.Buckets)] = buckets,
[nameof(AnalysisTable.Series)] = new[] { series },
[nameof(AnalysisTable.DrillHref)] = (Func<AnalysisBucket, string?>)(b => "/drill/" + b.FirstDay.Month),
});
Assert.Contains("<th scope=\"row\" class=\"mv-row-label\">Feb</th>", html, StringComparison.Ordinal);
Assert.Contains(">0 kWh", html, StringComparison.Ordinal);
Assert.Contains("mv-unknown\">—", html, StringComparison.Ordinal);
Assert.Contains("No data covers this period", html, StringComparison.Ordinal);
Assert.Contains("href=\"/drill/2\"", html, StringComparison.Ordinal);
Assert.Contains("aria-label=\"Details for Feb\"", html, StringComparison.Ordinal);
Assert.Contains("role=\"region\"", html, StringComparison.Ordinal);
}
[Fact]
public async Task A_metric_card_shows_words_instead_of_a_fabricated_zero()
{
var unknown = await RenderAsync<MetricCard>("en", new()
{
[nameof(MetricCard.Title)] = "Consumption",
[nameof(MetricCard.Value)] = Missing(),
[nameof(MetricCard.Unit)] = "kWh",
});
Assert.Contains("No data", unknown, StringComparison.Ordinal);
Assert.DoesNotContain("0 kWh", unknown, StringComparison.Ordinal);
var partial = await RenderAsync<MetricCard>("en", new()
{
[nameof(MetricCard.Title)] = "Consumption",
[nameof(MetricCard.Value)] = Partial(12),
[nameof(MetricCard.Unit)] = "kWh",
[nameof(MetricCard.Change)] = Change.Between(12, 10),
[nameof(MetricCard.Polarity)] = ChangePolarity.HigherIsWorse,
[nameof(MetricCard.ChangeCaption)] = "vs. last year",
});
Assert.Contains("12 kWh", partial, StringComparison.Ordinal);
Assert.Contains("Partial", partial, StringComparison.Ordinal);
Assert.Contains("2.0 kWh more (+20.0 %)", partial, StringComparison.Ordinal);
Assert.Contains("mv-change-bad", partial, StringComparison.Ordinal);
}
[Fact]
public async Task Empty_pending_and_error_states_offer_their_way_on()
{
var empty = await RenderAsync<EmptyPeriodState>("de", new()
{
[nameof(EmptyPeriodState.Availability)] = new AvailableRange(Now, Now, D(2020, 1, 1), D(2023, 3, 15)),
[nameof(EmptyPeriodState.LatestHref)] = "/meters/5?tab=analysis&from=2022-04-01&to=2023-03-31",
});
Assert.Contains("Keine Daten für diesen Zeitraum", empty, StringComparison.Ordinal);
Assert.Contains("Zu den neuesten Daten", empty, StringComparison.Ordinal);
Assert.Contains("from=2022-04-01", empty, StringComparison.Ordinal);
var nothing = await RenderAsync<EmptyPeriodState>("en", []);
Assert.Contains("No data yet", nothing, StringComparison.Ordinal);
Assert.DoesNotContain("Go to latest data", nothing, StringComparison.Ordinal);
var pending = await RenderAsync<PendingState>("en", []);
Assert.Contains("Analysis being prepared", pending, StringComparison.Ordinal);
var stale = await RenderAsync<PanelError>("en", new() { [nameof(PanelError.HasStaleValue)] = true });
Assert.Contains("The figures shown are from the previous selection.", stale, StringComparison.Ordinal);
var projection = await RenderAsync<ProjectionNote>("en", new() { [nameof(ProjectionNote.Days)] = 19, [nameof(ProjectionNote.ValueText)] = "320 kWh" });
Assert.Contains("Projection (straight-line from 19 days): ≈ 320 kWh", projection, StringComparison.Ordinal);
}
[Fact]
public async Task A_value_status_names_the_reason_and_the_source_it_misses()
{
var value = new BucketValue(null, BucketStatus.Missing, Provenance.Derived, ValueIssue.MissingSource, null, [9, 5]);
var html = await RenderAsync<ValueStatus>("de", new()
{
[nameof(ValueStatus.Value)] = value,
[nameof(ValueStatus.Inline)] = true,
[nameof(ValueStatus.MeterName)] = (Func<int, string?>)(id => id == 5 ? "Solar 2" : null),
});
Assert.Contains("Keine Daten · Berechnet", html, StringComparison.Ordinal);
Assert.Contains("Einem Quellzähler fehlen hier Daten (Solar 2)", html, StringComparison.Ordinal);
// A plain complete value says nothing unless asked to.
Assert.Equal(string.Empty, (await RenderAsync<ValueStatus>("en", new() { [nameof(ValueStatus.Value)] = Available(3) })).Trim());
Assert.Contains("Complete · Measured", await RenderAsync<ValueStatus>("en", new()
{
[nameof(ValueStatus.Value)] = Available(3),
[nameof(ValueStatus.ShowWhenComplete)] = true,
}), StringComparison.Ordinal);
}
[Fact]
public async Task A_comparison_summary_names_both_ranges_or_why_there_is_none()
{
var period = Range(D(2026, 1, 1), D(2026, 3, 31));
var shown = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear)),
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
});
Assert.Contains("Jan 1 Mar 31, 2026 compared with Jan 1 Mar 31, 2025", shown, StringComparison.Ordinal);
Assert.Contains("Not comparable", shown, StringComparison.Ordinal);
var all = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = new ComparisonResolution(ComparisonRequest.None, null, ComparisonUnavailableReason.AllHistory),
});
Assert.Contains("No comparison: All history has nothing before it to compare with.", all, StringComparison.Ordinal);
}
[Fact]
public async Task Attention_items_render_with_their_actions_and_severity_words()
{
var html = await RenderAsync<AttentionList>("en", new()
{
[nameof(AttentionList.Problems)] = new[] { new AnalysisProblem(AnalysisProblemKind.StaleSource, 3) },
[nameof(AttentionList.Names)] = new AttentionNames(new Dictionary<int, string> { [3] = "Solar 1" }),
});
Assert.Contains("Needs attention", html, StringComparison.Ordinal);
Assert.Contains("Warning:", html, StringComparison.Ordinal);
Assert.Contains("Solar 1: the live source has stopped delivering.", html, StringComparison.Ordinal);
Assert.Contains("href=\"/meters/3?tab=sources\"", html, StringComparison.Ordinal);
Assert.Equal(string.Empty, (await RenderAsync<AttentionList>("en", [])).Trim());
}
[Fact]
public async Task Virtual_sources_show_the_formula_with_names_and_nested_sources_indented()
{
var nested = new SeriesContribution(7, "Solar 2", false, 1, [], [], Available(150), 150, [9, 8, 7], []);
var inner = new SeriesContribution(8, "Dach", true, 1, [], [], Available(150), 150, [9, 8], [nested]);
var first = new SeriesContribution(3, "Solar 1", false, 1, [], [], Available(100), 100, [9, 3], []);
var series = Series(9, "Summe Solar", [Available(250)], kind: QuantityKind.Generation) with
{
Basis = SeriesBasis.Virtual,
Contributions = [first, inner],
Virtual = new VirtualSeriesInfo(VirtualMeterStatus.Valid, "m3 + m8", Core.Analysis.Virtual.VirtualCostRule.SourceCosts, [3, 8], [3, 7], [], null, null),
};
var html = await RenderAsync<SeriesContributions>("de", new() { [nameof(SeriesContributions.Series)] = series });
Assert.Contains(">Solar 1</a>", html, StringComparison.Ordinal);
Assert.Contains("<code>m3</code>", html, StringComparison.Ordinal);
Assert.Contains("<code>m8</code>", html, StringComparison.Ordinal);
// The nested source is indented with an invariant CSS number, even for a German reader.
Assert.Contains("padding-left:2rem", html, StringComparison.Ordinal);
Assert.Contains("· berechnet", html, StringComparison.Ordinal);
Assert.Contains("href=\"/meters/7?tab=analysis\"", html, StringComparison.Ordinal);
Assert.Contains("Kosten: Summe der Kosten der Quellen", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_chart_renders_its_container_or_says_there_is_nothing()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var none = await RenderAsync<AnalysisChart>("en", new()
{
[nameof(AnalysisChart.Buckets)] = buckets,
[nameof(AnalysisChart.Series)] = new[] { new AnalysisChartSeries("m1", "Haus", "kWh", [Missing(), Missing()]) },
});
Assert.Contains("No data in this range.", none, StringComparison.Ordinal);
var some = await RenderAsync<AnalysisChart>("en", new()
{
[nameof(AnalysisChart.Buckets)] = buckets,
[nameof(AnalysisChart.Series)] = new[] { new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Partial(2)]) },
[nameof(AnalysisChart.Title)] = "Consumption of Haus",
});
Assert.Contains("aria-label=\"Chart: Consumption of Haus. The table lists the same values.\"", some, StringComparison.Ordinal);
Assert.Contains("* Partial, estimated or not fully priced", some, StringComparison.Ordinal);
}
/// <summary>Renders a component and returns its HTML with entities decoded (the renderer encodes every non-ASCII letter).</summary>
private static async Task<string> RenderAsync<TComponent>(string culture, Dictionary<string, object?> parameters)
where TComponent : IComponent => WebUtility.HtmlDecode(await RenderRawAsync<TComponent>(culture, parameters));
private static async Task<string> RenderRawAsync<TComponent>(string culture, Dictionary<string, object?> parameters)
where TComponent : IComponent
{
await using var services = Services();
using var loggers = new NullLoggerFactory();
await using var renderer = new HtmlRenderer(services, loggers);
var previous = (System.Globalization.CultureInfo.CurrentCulture, System.Globalization.CultureInfo.CurrentUICulture);
try
{
System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo(culture);
System.Globalization.CultureInfo.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo(culture);
return await renderer.Dispatcher.InvokeAsync(async () =>
{
var output = await renderer.RenderComponentAsync<TComponent>(ParameterView.FromDictionary(parameters));
return output.ToHtmlString();
});
}
finally
{
System.Globalization.CultureInfo.CurrentCulture = previous.Item1;
System.Globalization.CultureInfo.CurrentUICulture = previous.Item2;
}
}
private static ServiceProvider Services()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMudServices();
services.AddSingleton<IJSRuntime, NoJs>();
services.AddSingleton<NavigationManager, TestNavigation>();
services.AddScoped<ThemeState>();
services.AddSingleton(Options.Create(new MeterVaultOptions()));
services.AddSingleton<InstanceCurrency>();
return services.BuildServiceProvider();
}
/// <summary>Static rendering runs no script; anything that asks gets nothing back.</summary>
private sealed class NoJs : IJSRuntime
{
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args) => ValueTask.FromResult(default(TValue)!);
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args) =>
ValueTask.FromResult(default(TValue)!);
}
private sealed class TestNavigation : NavigationManager
{
public TestNavigation() => Initialize("http://localhost/", "http://localhost/meters/5?tab=analysis");
protected override void NavigateToCore(string uri, NavigationOptions options)
{
}
}
}
@@ -0,0 +1,88 @@
using System.Globalization;
using MeterVault.App.Analysis;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>The CSV of the analysis table (D-55): invariant numbers, local ISO bounds, empty unknowns, safe text. Pure.</summary>
public sealed class AnalysisCsvWriterTests
{
private static readonly TimeSpan Cet = TimeSpan.FromHours(1);
[Fact]
public void Rows_are_written_with_invariant_numbers_whatever_the_culture()
{
var row = new AnalysisCsvRow(
"m12", "Zähler Haus", "Consumption", "kWh",
new DateTimeOffset(2024, 1, 1, 0, 0, 0, Cet), new DateTimeOffset(2024, 2, 1, 0, 0, 0, Cet), "Europe/Berlin",
1234.5, "Available", "Measured|Estimated", 370.35, "Priced", "EUR", 0.1 + 0.2);
var previous = CultureInfo.CurrentCulture;
string csv;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de");
csv = AnalysisCsvWriter.Write([row]);
}
finally
{
CultureInfo.CurrentCulture = previous;
}
var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(
"series_id,series_name,kind,unit,bucket_start,bucket_end,timezone,value,status,provenance,cost,cost_status,currency,comparison_value",
lines[0]);
Assert.Equal(
"m12,Zähler Haus,Consumption,kWh,2024-01-01T00:00:00+01:00,2024-02-01T00:00:00+01:00,Europe/Berlin,1234.5,Available,Measured|Estimated,370.35,Priced,EUR,0.30000000000000004",
lines[1]);
}
[Fact]
public void Unknown_values_are_empty_cells_never_zero()
{
var row = new AnalysisCsvRow(
"t3:use:kWh", "Strom · Total use", "Consumption", "kWh",
new DateTimeOffset(2026, 9, 1, 0, 0, 0, TimeSpan.FromHours(2)), new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)),
"Europe/Berlin", null, "Missing", string.Empty, null, null, null, double.NaN);
var line = AnalysisCsvWriter.Write([row]).Split("\r\n")[1];
Assert.Equal(
"t3:use:kWh,Strom · Total use,Consumption,kWh,2026-09-01T00:00:00+02:00,2026-09-19T14:37:00+02:00,Europe/Berlin,,Missing,,,,,",
line);
}
[Theory]
[InlineData("plain", "plain")]
[InlineData("a,b", "\"a,b\"")]
[InlineData("say \"hi\"", "\"say \"\"hi\"\"\"")]
[InlineData("two\nlines", "\"two\nlines\"")]
[InlineData("", "")]
[InlineData(null, "")]
public void Fields_are_quoted_as_rfc_4180_asks(string? field, string expected) =>
Assert.Equal(expected, AnalysisCsvWriter.Escape(field));
[Theory]
[InlineData("=HYPERLINK(\"x\")", "'=HYPERLINK(\"x\")")]
[InlineData("+1", "'+1")]
[InlineData("-Solar", "'-Solar")]
[InlineData("@home", "'@home")]
[InlineData("Solar 1", "Solar 1")]
public void User_text_cannot_become_a_spreadsheet_formula(string name, string expected) =>
Assert.Equal(expected, AnalysisCsvWriter.Text(name));
[Fact]
public async Task The_async_writer_writes_the_same_text()
{
var row = new AnalysisCsvRow(
"portfolio", "All energy types", "Cost", "EUR",
new DateTimeOffset(2024, 1, 1, 0, 0, 0, Cet), new DateTimeOffset(2024, 2, 1, 0, 0, 0, Cet), "Europe/Berlin",
-12.5, "Available", string.Empty, -12.5, "Priced", "EUR", null);
await using var writer = new StringWriter(CultureInfo.InvariantCulture);
await AnalysisCsvWriter.WriteAsync(writer, [row]);
Assert.Equal(AnalysisCsvWriter.Write([row]), writer.ToString());
Assert.Contains(",-12.5,Available,,-12.5,Priced,EUR,", writer.ToString(), StringComparison.Ordinal);
}
}
@@ -0,0 +1,813 @@
using Dapper;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Rollups;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using MeterVault.Infrastructure.Persistence.Analysis;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The analysis tables (D-12): day and month rollups, coverage runs and rollup state, written by the recompute from
/// the same rows it stores as consumption, by diff, in the caller's transaction — and rebuilt by the startup upgrade
/// (D-16). Checked against the database's own sums of <c>consumption</c>, the way a reader would query them.
/// </summary>
[Collection("Timescale")]
public sealed class AnalysisDataTests(TimescaleFixture fx)
{
private const string BerlinId = "Europe/Berlin";
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId);
/// <summary>The frozen "now" the recomputes stamp <see cref="MeterRollupState.BuiltAt"/> with.</summary>
private static readonly DateTimeOffset BuildTime = new(2026, 9, 19, 8, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Rollups_equal_consumption_summed_by_local_day_and_month_for_every_reference_meter()
{
await using var db = fx.CreateContext();
var meters = await ImportReferenceSheetsAsync(db);
try
{
foreach (var meterId in meters.All)
{
var days = await db.ConsumptionRollups.AsNoTracking().Where(r => r.MeterId == meterId)
.OrderBy(r => r.Day).ThenBy(r => r.Kind).ToListAsync();
var months = await db.ConsumptionRollupMonths.AsNoTracking().Where(r => r.MeterId == meterId)
.OrderBy(r => r.Month).ThenBy(r => r.Kind).ToListAsync();
Assert.NotEmpty(days);
AssertSameBuckets(await ConsumptionByAsync(db, meterId, "day"), days.Select(d => d.ToBucket()).ToList(), meterId);
AssertSameBuckets(await ConsumptionByAsync(db, meterId, "month"), months.Select(m => m.ToBucket()).ToList(), meterId);
}
// Generation stays generation; the electricity sheet's Solar 1 has only generation rows.
Assert.All(
await db.ConsumptionRollupMonths.AsNoTracking().Where(r => r.MeterId == meters.Solar1).ToListAsync(),
r => Assert.Equal(ConsumptionKind.Generation, r.Kind));
// Water, December 2022: the sheet's 14 m³, one imported month row, recorded until the month ends.
var december = await db.ConsumptionRollupMonths.AsNoTracking()
.SingleAsync(r => r.MeterId == meters.Wasser && r.Month == new DateOnly(2022, 12, 1));
Assert.Equal(14, december.Amount, 9);
Assert.Equal(14, december.Imported, 9);
Assert.Equal(1, december.Rows);
Assert.Equal(Provenance.Imported, december.ToBucket().Provenance);
}
finally
{
await DeleteAsync(db, meters.All);
}
}
[Fact]
public async Task A_label_rows_interval_ends_where_its_month_ends()
{
// A-05: "Dezember 2022" is the register at the end of December, stamped on the 1st. Its day and month
// rollups are recorded until the local midnight that ends December, so a reader whose now is inside
// December does not count it as an actual.
await using var db = fx.CreateContext();
var meters = await ImportReferenceSheetsAsync(db);
try
{
var endOfDecember = GapAttribution.LocalMidnight(new DateOnly(2023, 1, 1), Berlin);
var month = await db.ConsumptionRollupMonths.AsNoTracking()
.SingleAsync(r => r.MeterId == meters.Wasser && r.Month == new DateOnly(2022, 12, 1));
var day = await db.ConsumptionRollups.AsNoTracking()
.SingleAsync(r => r.MeterId == meters.Wasser && r.Day == new DateOnly(2022, 12, 1));
Assert.Equal(endOfDecember, month.MaxIntervalEnd);
Assert.Equal(endOfDecember, day.MaxIntervalEnd);
Assert.Equal(TimeSpan.Zero, month.MaxIntervalEnd.Offset);
// Every imported month of the sheet ends at its own month's end.
var all = await db.ConsumptionRollupMonths.AsNoTracking().Where(r => r.MeterId == meters.Wasser).ToListAsync();
Assert.All(all, r => Assert.Equal(GapAttribution.LocalMidnight(r.Month.AddMonths(1), Berlin), r.MaxIntervalEnd));
}
finally
{
await DeleteAsync(db, meters.All);
}
}
[Fact]
public async Task Coverage_runs_are_stored_for_the_seeded_water_meter()
{
await using var db = fx.CreateContext();
var meters = await ImportReferenceSheetsAsync(db);
try
{
var stored = await db.MeterCoverage.AsNoTracking().Where(r => r.MeterId == meters.Wasser)
.OrderBy(r => r.SpanFrom).ToListAsync();
// One month run from November 2022 to the end of May 2026, divided at months, across the swap.
var run = Assert.Single(stored);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2022, 11, 1), Berlin), run.SpanFrom);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2026, 6, 1), Berlin), run.SpanTo);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2026, 5, 1), Berlin), run.LastIntervalStart);
Assert.Equal(ResolutionClass.Month, run.ResolutionClass);
Assert.True(run.DividedAtMonths);
Assert.Equal(CoverageGapReason.None, run.GapReason);
// What is stored is exactly what the builder makes of the engine's rows.
Assert.Equal(await ExpectedCoverageAsync(db, meters.Wasser), stored.Select(r => r.ToRun()).ToList());
// The burner's twelve-year first interval is its own coarse run, and the tank's runs start at its
// first dipstick, not its first delivery (the rows stored match the builder there too).
Assert.Equal(await ExpectedCoverageAsync(db, meters.Burner), await RunsAsync(db, meters.Burner));
Assert.Equal(ResolutionClass.Coarse, (await RunsAsync(db, meters.Burner))[0].Resolution);
Assert.Equal(await ExpectedCoverageAsync(db, meters.OilTank), await RunsAsync(db, meters.OilTank));
}
finally
{
await DeleteAsync(db, meters.All);
}
}
[Fact]
public async Task Recomputing_unchanged_data_rewrites_nothing_and_a_new_reading_touches_only_what_it_moved()
{
await using var db = fx.CreateContext();
var meters = await ImportReferenceSheetsAsync(db);
var water = meters.Wasser;
try
{
var before = await VersionsAsync(db, water);
var state = await StateAsync(db, water);
Assert.Equal(BuildTime, state.BuiltAt);
// Same inputs, a later clock: not one row version changes, and the state keeps its build time.
await using (var again = fx.CreateContext())
{
await using var tx = await again.Database.BeginTransactionAsync();
await Normalization(again, time: new FixedTimeProvider(BuildTime.AddHours(1))).RecomputeMeterAsync(water, null);
await again.SaveChangesAsync();
await tx.CommitAsync();
}
Assert.Equal(before, await VersionsAsync(db, water));
Assert.Equal(BuildTime, (await StateAsync(db, water)).BuiltAt);
// A live reading ten days after the sheet's last month: one new day, one new month, the one run
// extended — every other row keeps its version.
var later = BuildTime.AddHours(2);
await using (var live = fx.CreateContext())
{
var ingestion = new IngestionService(live, Normalization(live, time: new FixedTimeProvider(later)));
var outcome = await ingestion.IngestByMeterAsync(water, Local(2026, 6, 11, 12), 700);
Assert.Equal(IngestionOutcome.Written, outcome);
}
var after = await VersionsAsync(db, water);
Assert.Equal(before.Days.Count + 1, after.Days.Count);
Assert.Empty(before.Days.Except(after.Days));
Assert.Equal("2026-06-11", Assert.Single(after.Days.Except(before.Days)).Split('|')[0]);
Assert.Equal(before.Months.Count + 1, after.Months.Count);
Assert.Empty(before.Months.Except(after.Months));
Assert.Single(after.Coverage);
Assert.NotEqual(before.Coverage, after.Coverage);
var run = await db.MeterCoverage.AsNoTracking().SingleAsync(r => r.MeterId == water);
Assert.Equal(Local(2026, 6, 11, 12), run.SpanTo);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2026, 6, 1), Berlin), run.LastIntervalStart);
var june = await db.ConsumptionRollupMonths.AsNoTracking().SingleAsync(r => r.MeterId == water && r.Month == new DateOnly(2026, 6, 1));
Assert.Equal(13, june.Amount, 9);
Assert.Equal(13, june.Measured, 9);
Assert.Equal(later, (await StateAsync(db, water)).BuiltAt);
}
finally
{
await DeleteAsync(db, meters.All);
}
}
[Fact]
public async Task Several_recomputes_before_one_save_leave_exactly_the_last_result()
{
// One context may recompute a meter repeatedly before it saves. Rows a pass removed come back when the next
// pass wants them again, and rows a pass added go when the next one does not.
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
try
{
await using (var setup = fx.CreateContext())
{
var ingestion = new IngestionService(setup, Normalization(setup));
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 3, 9), 700, quality: ReadingQuality.Manual);
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 4, 9), 710, quality: ReadingQuality.Manual);
}
var original = await DaysAsync(db, meterId);
Assert.Equal([new DateOnly(2026, 8, 3), new DateOnly(2026, 8, 4)], original.Select(d => d.Start));
await using (var ctx = fx.CreateContext())
{
await using var tx = await ctx.Database.BeginTransactionAsync();
var normalization = Normalization(ctx);
// Pass 1: 4 August gone, 5 August new.
await ctx.Readings.Where(r => r.MeterId == meterId && r.Time == Local(2026, 8, 4, 9)).ExecuteDeleteAsync();
await InsertReadingAsync(ctx, meterId, Local(2026, 8, 5, 9), 715);
await normalization.RecomputeMeterAsync(meterId, null);
// Pass 2: back to 3 and 4 August.
await InsertReadingAsync(ctx, meterId, Local(2026, 8, 4, 9), 710);
await ctx.Readings.Where(r => r.MeterId == meterId && r.Time == Local(2026, 8, 5, 9)).ExecuteDeleteAsync();
await normalization.RecomputeMeterAsync(meterId, null);
await ctx.SaveChangesAsync();
await tx.CommitAsync();
}
Assert.Equal(original, await DaysAsync(db, meterId));
Assert.Equal(710, (await db.ConsumptionRollupMonths.AsNoTracking().SingleAsync(r => r.MeterId == meterId)).Amount, 9);
}
finally
{
await DeleteAsync(db, meterId);
}
}
[Fact]
public async Task Rows_removed_behind_the_trackers_back_are_written_again()
{
// A long-lived context (a worker's scope) still tracks the rows it saved. When they are deleted outside it,
// the next recompute must add them again rather than trust its stale copies.
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
try
{
await using var worker = fx.CreateContext();
var ingestion = new IngestionService(worker, Normalization(worker));
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 3, 9), 700);
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 4, 9), 710);
await db.ConsumptionRollups.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.MeterCoverage.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
Assert.Equal(IngestionOutcome.Written, await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 5, 9), 725));
Assert.Equal(
[(new DateOnly(2026, 8, 3), 700d), (new DateOnly(2026, 8, 4), 10d), (new DateOnly(2026, 8, 5), 15d)],
(await DaysAsync(db, meterId)).Select(d => (d.Start, d.Amount)));
Assert.Equal(Local(2026, 8, 5, 9), Assert.Single(await RunsAsync(db, meterId)).To);
}
finally
{
await DeleteAsync(db, meterId);
}
}
[Fact]
public async Task An_instant_first_reading_flags_its_day_as_an_opening_balance_until_an_install_date_says_since_when()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
try
{
await using (var live = fx.CreateContext())
{
var ingestion = new IngestionService(live, Normalization(live));
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 3, 9), 700, quality: ReadingQuality.Manual);
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 4, 9), 710, quality: ReadingQuality.Manual);
}
var days = await DaysAsync(db, meterId);
Assert.Equal([RollupFlags.OpeningBalance, RollupFlags.None], days.Select(d => d.Flags));
Assert.Equal(RollupFlags.OpeningBalance, (await db.ConsumptionRollupMonths.AsNoTracking().SingleAsync(r => r.MeterId == meterId)).Flags);
// The opening balance is no coverage (A-01): only the day between the two readings is covered.
var run = Assert.Single(await RunsAsync(db, meterId));
Assert.Equal(Local(2026, 8, 3, 9), run.From);
Assert.Equal(Local(2026, 8, 4, 9), run.To);
// An install date is an engine input (D-10): the first reading now counts from it.
await db.Meters.Where(m => m.Id == meterId)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.InstalledAt, (DateOnly?)new DateOnly(2026, 7, 1)));
await RecomputeAsync(meterId);
Assert.All(await DaysAsync(db, meterId), d => Assert.Equal(RollupFlags.None, d.Flags));
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2026, 7, 1), Berlin), (await RunsAsync(db, meterId))[0].From);
}
finally
{
await DeleteAsync(db, meterId);
}
}
[Fact]
public async Task A_virtual_meter_stores_no_series_only_its_state()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "kWh");
var legacyId = await CreateMeterAsync(db, MeterMode.Virtual, "kWh");
try
{
await using (var live = fx.CreateContext())
{
var ingestion = new IngestionService(live, Normalization(live));
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 3, 9), 700);
await ingestion.IngestByMeterAsync(meterId, Local(2026, 8, 4, 9), 710);
}
Assert.NotEmpty(await DaysAsync(db, meterId));
// The meter becomes a virtual generation sum: its physical series, rollups and coverage go.
await db.Meters.Where(m => m.Id == meterId).ExecuteUpdateAsync(s => s
.SetProperty(m => m.Mode, MeterMode.Virtual)
.SetProperty(m => m.Meta, """{"expression":"m1 + m2","referencedMeterIds":[1,2],"resultKind":"generation","resultUnit":"kWh","costRule":"sourceCosts"}"""));
await RecomputeAsync(meterId);
await RecomputeAsync(legacyId);
Assert.False(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
Assert.Empty(await DaysAsync(db, meterId));
Assert.False(await db.ConsumptionRollupMonths.AnyAsync(r => r.MeterId == meterId));
Assert.Empty(await RunsAsync(db, meterId));
var state = await StateAsync(db, meterId);
Assert.Equal(QuantityKind.Generation, state.Kind);
Assert.Equal("kWh", state.NormalizedUnit);
Assert.Equal(NormalizationUpgrade.CurrentRevision, state.Revision);
Assert.Equal(BerlinId, state.Zone);
// A legacy virtual meter without a definition is recorded as undeclared consumption in its own unit.
var legacy = await StateAsync(db, legacyId);
Assert.Equal(QuantityKind.Consumption, legacy.Kind);
Assert.Equal("kWh", legacy.NormalizedUnit);
}
finally
{
await DeleteAsync(db, meterId, legacyId);
}
}
[Fact]
public async Task The_state_records_the_normalized_quantity_not_the_raw_unit()
{
await using var db = fx.CreateContext();
var water = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
var export = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "kWh");
var sensor = await CreateMeterAsync(db, MeterMode.InstantRate, "kW");
await db.Meters.Where(m => m.Id == export).ExecuteUpdateAsync(s => s.SetProperty(m => m.Meta, """{"role":"grid_export"}"""));
try
{
await RecomputeAsync(water, export, sensor);
Assert.Equal((QuantityKind.Consumption, "m³"), Quantity(await StateAsync(db, water)));
Assert.Equal((QuantityKind.Export, "kWh"), Quantity(await StateAsync(db, export)));
Assert.Equal((QuantityKind.Consumption, "kWh"), Quantity(await StateAsync(db, sensor)));
}
finally
{
await DeleteAsync(db, water, export, sensor);
}
static (QuantityKind, string) Quantity(MeterRollupState state) => (state.Kind, state.NormalizedUnit);
}
[Fact]
public async Task Deleting_a_meter_takes_its_analysis_rows_with_it()
{
await using var db = fx.CreateContext();
var meters = await ImportReferenceSheetsAsync(db);
var ids = meters.All;
Assert.True(await db.ConsumptionRollups.AnyAsync(r => ids.Contains(r.MeterId)));
Assert.True(await db.ConsumptionRollupMonths.AnyAsync(r => ids.Contains(r.MeterId)));
Assert.True(await db.MeterCoverage.AnyAsync(r => ids.Contains(r.MeterId)));
Assert.Equal(ids.Length, await db.MeterRollupStates.CountAsync(r => ids.Contains(r.MeterId)));
// As the meter list deletes: consumption and readings first (restrict), then the meter itself.
await DeleteAsync(db, ids);
Assert.False(await db.ConsumptionRollups.AnyAsync(r => ids.Contains(r.MeterId)));
Assert.False(await db.ConsumptionRollupMonths.AnyAsync(r => ids.Contains(r.MeterId)));
Assert.False(await db.MeterCoverage.AnyAsync(r => ids.Contains(r.MeterId)));
Assert.False(await db.MeterRollupStates.AnyAsync(r => ids.Contains(r.MeterId)));
}
[Fact]
public async Task The_upgrade_builds_rollups_and_state_for_revision_2_data_including_virtual_meters()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
var virtualId = await CreateMeterAsync(db, MeterMode.Virtual, "m3");
db.Readings.AddRange(
new Reading { MeterId = meterId, Time = Local(2026, 8, 1, 9), Value = 700, Quality = ReadingQuality.Manual },
new Reading { MeterId = meterId, Time = Local(2026, 9, 16, 18), Value = 746, Quality = ReadingQuality.Manual });
// What revision 2 stored: consumption only, and a series for the virtual meter nothing ever read.
db.Consumption.AddRange(
new Consumption { MeterId = meterId, Time = Local(2026, 8, 1, 9), Amount = 700, Quality = ReadingQuality.Manual },
new Consumption { MeterId = meterId, Time = Local(2026, 9, 16, 18), Amount = 46, Quality = ReadingQuality.Manual },
new Consumption { MeterId = virtualId, Time = Local(2026, 9, 1, 0), Amount = 5, Quality = ReadingQuality.Estimated });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
await SetSettingsAsync(db, revision: 2, zone: BerlinId);
try
{
Assert.True(await Upgrade(db).RunAsync() >= 2);
var state = await StateAsync(db, meterId);
Assert.Equal(NormalizationUpgrade.CurrentRevision, state.Revision);
Assert.Equal(BerlinId, state.Zone);
Assert.Equal(BuildTime, state.BuiltAt);
Assert.Equal((QuantityKind.Consumption, "m³"), (state.Kind, state.NormalizedUnit));
// Rollups exist and agree with the rebuilt consumption, August holding its share of the six weeks.
AssertSameBuckets(await ConsumptionByAsync(db, meterId, "month"),
(await db.ConsumptionRollupMonths.AsNoTracking().Where(r => r.MeterId == meterId).OrderBy(r => r.Month).ToListAsync())
.Select(m => m.ToBucket()).ToList(), meterId);
Assert.True((await db.ConsumptionRollupMonths.AsNoTracking()
.SingleAsync(r => r.MeterId == meterId && r.Month == new DateOnly(2026, 8, 1))).Amount > 730);
Assert.NotEmpty(await RunsAsync(db, meterId));
// The virtual meter is part of the rebuild: its stray series is purged and its state recorded.
Assert.False(await db.Consumption.AnyAsync(c => c.MeterId == virtualId));
Assert.Equal(NormalizationUpgrade.CurrentRevision, (await StateAsync(db, virtualId)).Revision);
var revision = await db.AppSettings.AsNoTracking().SingleAsync(s => s.Key == NormalizationUpgrade.SettingKey);
Assert.Equal(NormalizationUpgrade.CurrentRevision.ToString(System.Globalization.CultureInfo.InvariantCulture), revision.Value);
Assert.Equal(0, await Upgrade(db).RunAsync());
}
finally
{
await DeleteAsync(db, meterId, virtualId);
}
}
[Fact]
public async Task The_upgrade_rebuilds_a_meter_whose_state_is_missing_although_the_revision_is_current()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
try
{
// The instance is current; only the new meter, never recomputed, lacks a state.
await SetSettingsAsync(db, revision: NormalizationUpgrade.CurrentRevision, zone: BerlinId);
Assert.False(await db.MeterRollupStates.AnyAsync(s => s.MeterId == meterId));
Assert.True(await Upgrade(db).RunAsync() >= 1);
Assert.Equal(BerlinId, (await StateAsync(db, meterId)).Zone);
Assert.Equal(0, await Upgrade(db).RunAsync());
}
finally
{
await DeleteAsync(db, meterId);
}
}
[Fact]
public async Task A_zone_change_rebuilds_rollups_in_the_new_zone()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
var first = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero);
var late = new DateTimeOffset(2026, 1, 20, 23, 30, 0, TimeSpan.Zero); // 20 January in London, 21st in Berlin
try
{
await using (var london = fx.CreateContext())
{
var ingestion = new IngestionService(london, Normalization(london, "Europe/London"));
await ingestion.IngestByMeterAsync(meterId, first, 100);
await ingestion.IngestByMeterAsync(meterId, late, 130);
}
Assert.Equal("Europe/London", (await StateAsync(db, meterId)).Zone);
Assert.Contains(new DateOnly(2026, 1, 20), (await DaysAsync(db, meterId)).Select(d => d.Start));
await SetSettingsAsync(db, revision: NormalizationUpgrade.CurrentRevision, zone: "Europe/London");
Assert.True(await Upgrade(db).RunAsync() >= 1);
Assert.Equal(BerlinId, (await StateAsync(db, meterId)).Zone);
var days = (await DaysAsync(db, meterId)).Select(d => d.Start).ToList();
Assert.Contains(new DateOnly(2026, 1, 21), days);
Assert.DoesNotContain(new DateOnly(2026, 1, 20), days);
var zone = await db.AppSettings.AsNoTracking().SingleAsync(s => s.Key == NormalizationUpgrade.ZoneSettingKey);
Assert.Equal($"\"{BerlinId}\"", zone.Value);
}
finally
{
await DeleteAsync(db, meterId);
}
}
[Fact]
public async Task The_upgrade_skips_a_meter_whose_history_is_older_than_its_readings_instead_of_cutting_it_off()
{
await using var db = fx.CreateContext();
var truncated = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
var midnight = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3", installedAt: new DateOnly(2026, 5, 1));
// Consumption from 2020 whose readings are gone — what raw retention would leave behind.
db.Readings.AddRange(
new Reading { MeterId = truncated, Time = Local(2026, 6, 1, 9), Value = 900, Quality = ReadingQuality.Manual },
new Reading { MeterId = truncated, Time = Local(2026, 6, 2, 9), Value = 910, Quality = ReadingQuality.Manual });
db.Consumption.AddRange(
new Consumption { MeterId = truncated, Time = Local(2020, 3, 1, 9), Amount = 400, Quality = ReadingQuality.Manual },
new Consumption { MeterId = truncated, Time = Local(2026, 6, 1, 9), Amount = 500, Quality = ReadingQuality.Manual });
// A first reading at exactly local midnight after an install date is booked one second before it (D-11):
// that is not lost history.
db.Readings.Add(new Reading { MeterId = midnight, Time = Local(2026, 6, 1, 0), Value = 50, Quality = ReadingQuality.Manual });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
try
{
await RecomputeAsync(midnight);
Assert.Equal(Local(2026, 6, 1, 0).AddSeconds(-1), await db.Consumption.Where(c => c.MeterId == midnight).MinAsync(c => c.Time));
// As revision 2 left it: consumption, but no analysis data yet.
await db.MeterRollupStates.Where(s => s.MeterId == midnight).ExecuteDeleteAsync();
await db.ConsumptionRollups.Where(r => r.MeterId == midnight).ExecuteDeleteAsync();
await SetSettingsAsync(db, revision: 2, zone: BerlinId);
await Upgrade(db).RunAsync();
Assert.Equal(900, await db.Consumption.Where(c => c.MeterId == truncated).SumAsync(c => c.Amount), 9);
Assert.Equal(Local(2020, 3, 1, 9), await db.Consumption.Where(c => c.MeterId == truncated).MinAsync(c => c.Time));
Assert.False(await db.MeterRollupStates.AnyAsync(s => s.MeterId == truncated));
Assert.Equal(NormalizationUpgrade.CurrentRevision, (await StateAsync(db, midnight)).Revision);
Assert.True(await db.ConsumptionRollups.AnyAsync(r => r.MeterId == midnight));
// Checked again at the next start, and skipped again — never counted as rebuilt, never pending.
Assert.Equal(0, await Upgrade(db).RunAsync());
Assert.False(await db.MeterRollupStates.AnyAsync(s => s.MeterId == truncated));
var pending = await db.AppSettings.AsNoTracking().SingleAsync(s => s.Key == NormalizationUpgrade.PendingSettingKey);
Assert.Equal("[]", pending.Value);
}
finally
{
await DeleteAsync(db, truncated, midnight);
}
}
// ---- helpers ----
private sealed record ReferenceMeters(int Haus, int Netz, int Auto, int Solar1, int Solar2, int Wasser, int OilTank, int Burner)
{
public int[] All => [Haus, Netz, Auto, Solar1, Solar2, Wasser, OilTank, Burner];
}
/// <summary>A Berlin wall-clock time as the UTC instant the database stores.</summary>
private static DateTimeOffset Local(int year, int month, int day, int hour, int minute = 0)
{
var wall = new DateTime(year, month, day, hour, minute, 0);
return new DateTimeOffset(wall, Berlin.GetUtcOffset(wall)).ToUniversalTime();
}
private static NormalizationService Normalization(MeterVaultDbContext db, string zone = BerlinId, TimeProvider? time = null) =>
new(db, NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = zone }),
time ?? new FixedTimeProvider(BuildTime));
private static NormalizationUpgrade Upgrade(MeterVaultDbContext db) =>
new(db, Normalization(db), NullLogger<NormalizationUpgrade>.Instance);
private async Task RecomputeAsync(params int[] meterIds)
{
await using var db = fx.CreateContext();
await using var tx = await db.Database.BeginTransactionAsync();
foreach (var meterId in meterIds)
{
await Normalization(db).RecomputeMeterAsync(meterId, null);
}
await db.SaveChangesAsync();
await tx.CommitAsync();
}
private static async Task<int> CreateMeterAsync(MeterVaultDbContext db, MeterMode mode, string unit, DateOnly? installedAt = null)
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "water");
var meter = new Meter
{
Name = $"analysis-{Guid.NewGuid():N}",
EnergyTypeId = type.Id,
Mode = mode,
Unit = unit,
InstalledAt = installedAt,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
return meter.Id;
}
private static async Task InsertReadingAsync(MeterVaultDbContext db, int meterId, DateTimeOffset time, double value) =>
await db.Database.ExecuteSqlInterpolatedAsync(
$"INSERT INTO reading (meter_id, \"time\", value, quality, flags) VALUES ({meterId}, {time.ToUniversalTime()}, {value}, {(short)ReadingQuality.Manual}, 0)");
/// <summary>Creates the eight reference meters under fresh names and imports three sheets into them, in Berlin.</summary>
private static async Task<ReferenceMeters> ImportReferenceSheetsAsync(MeterVaultDbContext db)
{
await DatabaseSeeder.SeedAsync(db);
var electricity = (await db.EnergyTypes.FirstAsync(t => t.Key == "electricity")).Id;
var water = (await db.EnergyTypes.FirstAsync(t => t.Key == "water")).Id;
var oil = (await db.EnergyTypes.FirstAsync(t => t.Key == "heating_oil")).Id;
var suffix = Guid.NewGuid().ToString("N");
Meter Create(string name, short type, MeterMode mode, string unit, double baseline = 0) =>
new() { Name = $"{name} {suffix}", EnergyTypeId = type, Mode = mode, Unit = unit, InitialBaseline = baseline };
var haus = Create("Haus", electricity, MeterMode.CumulativeCounter, "kWh");
var netz = Create("Netz", electricity, MeterMode.CumulativeCounter, "kWh");
var auto = Create("Auto", electricity, MeterMode.CumulativeCounter, "kWh");
var solar1 = Create("Solar 1", electricity, MeterMode.GenerationCounter, "kWh");
var solar2 = Create("Solar 2", electricity, MeterMode.GenerationCounter, "kWh");
var wasser = Create("Wasser", water, MeterMode.CumulativeCounter, "m3", baseline: 820);
var tank = Create("Öltank", oil, MeterMode.ConsumableBalance, "L");
var burner = Create("Brenner", oil, MeterMode.RuntimeCounter, "h");
db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, tank, burner);
await db.SaveChangesAsync();
db.Tanks.Add(new Tank
{
MeterId = tank.Id,
Capacity = 7000,
Unit = "L",
Calibration = MeterConfigFactory.SerializeCalibration(new CalibrationCurve(ReferenceProfiles.OilLitresPerCm)),
});
await db.SaveChangesAsync();
var ids = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, tank.Id, burner.Id);
var import = new ImportService(db, Normalization(db));
foreach (var (profile, file) in new[]
{
(ReferenceProfiles.Electricity(ids), Electricity),
(ReferenceProfiles.Water(ids), Water),
(ReferenceProfiles.HeatingOil(ids), Oil),
})
{
await import.CommitAsync(Stage(profile, file), file, null);
}
db.ChangeTracker.Clear();
return new ReferenceMeters(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, tank.Id, burner.Id);
}
/// <summary>Removes meters the way the meter list does; the analysis tables go with them (cascade).</summary>
private static async Task DeleteAsync(MeterVaultDbContext db, params int[] meterIds)
{
var batches = await db.Readings.Where(r => meterIds.Contains(r.MeterId) && r.ImportBatchId != null)
.Select(r => r.ImportBatchId!.Value).Distinct().ToListAsync();
await db.Consumption.Where(c => meterIds.Contains(c.MeterId)).ExecuteDeleteAsync();
await db.Readings.Where(r => meterIds.Contains(r.MeterId)).ExecuteDeleteAsync();
await db.MeterEvents.Where(e => meterIds.Contains(e.MeterId)).ExecuteDeleteAsync();
await db.Meters.Where(m => meterIds.Contains(m.Id)).ExecuteDeleteAsync();
await db.ImportBatches.Where(b => batches.Contains(b.Id)).ExecuteDeleteAsync();
}
private static async Task SetSettingsAsync(MeterVaultDbContext db, int revision, string zone)
{
foreach (var (key, value) in new[]
{
(NormalizationUpgrade.SettingKey, revision.ToString(System.Globalization.CultureInfo.InvariantCulture)),
(NormalizationUpgrade.ZoneSettingKey, System.Text.Json.JsonSerializer.Serialize(zone)),
(NormalizationUpgrade.PendingSettingKey, "[]"),
})
{
var setting = await db.AppSettings.FirstOrDefaultAsync(s => s.Key == key);
if (setting is null)
{
db.AppSettings.Add(new AppSetting { Key = key, Value = value });
}
else
{
setting.Value = value;
}
}
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
}
private static async Task<MeterRollupState> StateAsync(MeterVaultDbContext db, int meterId) =>
await db.MeterRollupStates.AsNoTracking().SingleAsync(s => s.MeterId == meterId);
private static async Task<List<RollupBucket>> DaysAsync(MeterVaultDbContext db, int meterId) =>
(await db.ConsumptionRollups.AsNoTracking().Where(r => r.MeterId == meterId)
.OrderBy(r => r.Day).ThenBy(r => r.Kind).ToListAsync())
.Select(r => r.ToBucket()).ToList();
private static async Task<List<CoverageRun>> RunsAsync(MeterVaultDbContext db, int meterId) =>
(await db.MeterCoverage.AsNoTracking().Where(r => r.MeterId == meterId).OrderBy(r => r.SpanFrom).ToListAsync())
.Select(r => r.ToRun()).ToList();
/// <summary>The coverage the builder makes of a fresh normalization of the meter's stored inputs, in Berlin.</summary>
private static async Task<List<CoverageRun>> ExpectedCoverageAsync(MeterVaultDbContext db, int meterId)
{
var meter = await db.Meters.AsNoTracking().SingleAsync(m => m.Id == meterId);
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId);
var rows = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = MeterConfigFactory.FromMeter(meter, tank),
Readings = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToListAsync(),
Events = await db.MeterEvents.AsNoTracking().Where(e => e.MeterId == meterId).OrderBy(e => e.Time).ToListAsync(),
TimeZone = Berlin,
});
return [.. CoverageBuilder.Build(rows, Berlin)];
}
/// <summary>What <c>consumption</c> itself sums to per Berlin day or month, split by quality like the rollups.</summary>
private static async Task<List<RollupBucket>> ConsumptionByAsync(MeterVaultDbContext db, int meterId, string unit)
{
var bucket = unit == "day"
? "(\"time\" AT TIME ZONE 'Europe/Berlin')::date"
: "date_trunc('month', \"time\" AT TIME ZONE 'Europe/Berlin')::date";
var sql = $"""
SELECT {bucket} AS start, kind,
sum(amount) AS amount,
coalesce(sum(amount) FILTER (WHERE quality = 0), 0) AS measured,
coalesce(sum(amount) FILTER (WHERE quality = 2), 0) AS manual,
coalesce(sum(amount) FILTER (WHERE quality = 3), 0) AS imported,
coalesce(sum(amount) FILTER (WHERE quality IN (1, 4)), 0) AS estimated,
count(*)::int AS rows
FROM consumption WHERE meter_id = @meterId
GROUP BY 1, 2 ORDER BY 1, 2
""";
var rows = await db.Database.GetDbConnection().QueryAsync<SqlBucket>(sql, new { meterId });
return rows.Select(r => new RollupBucket(
r.Start, (ConsumptionKind)r.Kind, r.Amount, r.Measured, r.Manual, r.Imported, r.Estimated,
r.Rows, RollupFlags.None, DateTimeOffset.MinValue)).ToList();
}
private static void AssertSameBuckets(List<RollupBucket> expected, List<RollupBucket> actual, int meterId)
{
Assert.Equal(expected.Select(b => (b.Start, b.Kind)), actual.Select(b => (b.Start, b.Kind)));
foreach (var (e, a) in expected.Zip(actual))
{
var label = $"meter {meterId}, {e.Start:yyyy-MM-dd} {e.Kind}";
AssertClose(e.Amount, a.Amount, label + " amount");
AssertClose(e.Measured, a.Measured, label + " measured");
AssertClose(e.Manual, a.Manual, label + " manual");
AssertClose(e.Imported, a.Imported, label + " imported");
AssertClose(e.Estimated, a.Estimated, label + " estimated");
Assert.True(e.Rows == a.Rows, $"{label}: {a.Rows} rows, consumption has {e.Rows}");
}
}
private static void AssertClose(double expected, double actual, string label) =>
Assert.True(Math.Abs(expected - actual) <= 1e-9 * Math.Max(1, Math.Abs(expected)), $"{label}: {actual} vs {expected}");
/// <summary>Every analysis row of the meter with its tuple version: an UPDATE or a DELETE + INSERT changes it.</summary>
private static async Task<RowVersions> VersionsAsync(MeterVaultDbContext db, int meterId)
{
var connection = db.Database.GetDbConnection();
async Task<List<string>> Query(string sql) => [.. await connection.QueryAsync<string>(sql, new { meterId })];
return new RowVersions(
await Query("SELECT concat_ws('|', day, kind, xmin, ctid) FROM consumption_rollup WHERE meter_id = @meterId ORDER BY 1"),
await Query("SELECT concat_ws('|', month, kind, xmin, ctid) FROM consumption_rollup_month WHERE meter_id = @meterId ORDER BY 1"),
await Query("SELECT concat_ws('|', span_from, xmin, ctid) FROM meter_coverage WHERE meter_id = @meterId ORDER BY 1"),
await Query("SELECT concat_ws('|', meter_id, xmin, ctid) FROM meter_rollup_state WHERE meter_id = @meterId ORDER BY 1"));
}
private sealed record RowVersions(List<string> Days, List<string> Months, List<string> Coverage, List<string> State)
{
public bool Equals(RowVersions? other) =>
other is not null && Days.SequenceEqual(other.Days) && Months.SequenceEqual(other.Months)
&& Coverage.SequenceEqual(other.Coverage) && State.SequenceEqual(other.State);
public override int GetHashCode() => HashCode.Combine(Days.Count, Months.Count, Coverage.Count, State.Count);
}
private sealed class SqlBucket
{
public DateOnly Start { get; set; }
public short Kind { get; set; }
public double Amount { get; set; }
public double Measured { get; set; }
public double Manual { get; set; }
public double Imported { get; set; }
public double Estimated { get; set; }
public int Rows { get; set; }
}
}
@@ -0,0 +1,236 @@
using System.Globalization;
using System.Net;
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.Core.Domain;
using MeterVault.Integration.Tests.Costing;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// <c>GET /export/analysis.csv</c> (D-55) end to end: the page's URL keys resolved by the same query code, quantities
/// from the analysis reader and costs from the cost reader, one row per bucket and series with local bounds, the
/// comparison beside each value — and a 400 for anything it cannot answer. The app runs on the frozen clock of
/// <see cref="CostSandbox.Now"/> (19 September 2026, 14:37 Berlin).
/// </summary>
[Collection("Timescale")]
public sealed class AnalysisExportEndpointTests(TimescaleFixture fx)
{
[Fact]
public async Task A_meter_exports_its_quantity_cost_and_comparison_per_bucket()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2023, 1, 1), 80, 40, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 100, 50);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
using var app = new FrozenApp(fx.ConnectionString);
using var response = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=meter&id={meter}&from=2024-01-01&to=2024-02-29&bucket=month"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
var disposition = response.Content.Headers.ContentDisposition!;
Assert.Equal("attachment", disposition.DispositionType);
Assert.Equal($"metervault-meter-{meter}-quantity-2024-01-01-2024-02-29.csv", disposition.FileNameStar ?? disposition.FileName?.Trim('"'));
var rows = await RowsAsync(response);
Assert.Equal(2, rows.Count);
var january = rows[0];
Assert.Equal("m" + meter.ToString(CultureInfo.InvariantCulture), january["series_id"]);
Assert.Equal("Consumption", january["kind"]);
Assert.Equal("kWh", january["unit"]);
Assert.Equal("2024-01-01T00:00:00+01:00", january["bucket_start"]);
Assert.Equal("2024-02-01T00:00:00+01:00", january["bucket_end"]);
Assert.Equal("Europe/Berlin", january["timezone"]);
Assert.Equal(100, Number(january["value"]), 6);
Assert.Equal("Available", january["status"]);
Assert.Equal(30, Number(january["cost"]), 6);
Assert.Equal("Priced", january["cost_status"]);
Assert.Equal("EUR", january["currency"]);
// The default comparison is the previous year (A-13): January 2023.
Assert.Equal(80, Number(january["comparison_value"]), 6);
var february = rows[1];
Assert.Equal("2024-02-01T00:00:00+01:00", february["bucket_start"]);
Assert.Equal("2024-03-01T00:00:00+01:00", february["bucket_end"]);
Assert.Equal(50, Number(february["value"]), 6);
Assert.Equal(15, Number(february["cost"]), 6);
Assert.Equal(40, Number(february["comparison_value"]), 6);
}
[Fact]
public async Task The_cost_metric_exports_the_scope_s_bill_with_its_comparison()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2023, 1, 1), 80, 40, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 100, 50);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
using var app = new FrozenApp(fx.ConnectionString);
using var response = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=type&id={type}&metric=cost&from=2024-01-01&to=2024-02-29&bucket=month"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var rows = await RowsAsync(response);
Assert.Equal(2, rows.Count);
Assert.All(rows, r =>
{
Assert.Equal("t" + type.ToString(CultureInfo.InvariantCulture), r["series_id"]);
Assert.Equal("Cost test", r["series_name"]);
Assert.Equal("Cost", r["kind"]);
Assert.Equal("EUR", r["unit"]);
Assert.Equal("Priced", r["cost_status"]);
Assert.Equal(r["value"], r["cost"]);
});
Assert.Equal(30, Number(rows[0]["value"]), 6);
Assert.Equal(24, Number(rows[0]["comparison_value"]), 6);
Assert.Equal(15, Number(rows[1]["value"]), 6);
Assert.Equal(12, Number(rows[1]["comparison_value"]), 6);
// Without a comparison the column stays empty.
using var plain = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=type&id={type}&metric=cost&from=2024-01-01&to=2024-02-29&bucket=month&compare=none"));
Assert.All(await RowsAsync(plain), r => Assert.Equal(string.Empty, r["comparison_value"]));
}
[Fact]
public async Task A_month_with_nothing_booked_is_exported_as_no_data_not_as_an_available_priced_blank()
{
// §4.3 "one meaning across tables and exports": a cost bucket with nothing to bill is unknown, so its row never
// pairs an empty value with "Available" and "Priced". A priced month keeps its figures.
await using var box = new CostSandbox(fx);
var category = await box.CategoryAsync($"export-{Guid.NewGuid():N}", 97);
await box.ManualCostAsync(D(2024, 1, 10), 30, categoryId: category);
using var app = new FrozenApp(fx.ConnectionString);
using var response = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=category&id={category}&from=2024-01-01&to=2024-03-31&bucket=month&compare=none"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var rows = await RowsAsync(response);
Assert.Equal(3, rows.Count);
Assert.Equal(30, Number(rows[0]["value"]), 6);
Assert.Equal("Available", rows[0]["status"]);
foreach (var row in rows.Skip(1))
{
Assert.Equal(string.Empty, row["value"]);
Assert.NotEqual("Available", row["status"]);
}
}
[Fact]
public async Task A_type_exports_its_measures_and_a_selection_each_meter()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var first = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
var second = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 10, 20);
using var app = new FrozenApp(fx.ConnectionString);
var range = "&from=2024-01-01&to=2024-02-29&bucket=month&compare=none";
using var measures = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=type&id={type}&metric=consumption{range}"));
var use = Assert.Single((await RowsAsync(measures)).GroupBy(r => r["series_id"]));
Assert.StartsWith($"t{type}:use:", use.Key, StringComparison.Ordinal);
Assert.Equal([110d, 70d], use.Select(r => Number(r["value"])));
// A measure has no cost of its own: the cost columns stay empty rather than read as zero.
Assert.All(use, r => Assert.Equal(string.Empty, r["cost_status"]));
using var selection = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=meters&ids={first},{second}{range}"));
var bySeries = (await RowsAsync(selection)).GroupBy(r => r["series_id"]).ToDictionary(g => g.Key, g => g.Select(r => Number(r["value"])).ToList());
Assert.Equal([100d, 50d], bySeries["m" + first.ToString(CultureInfo.InvariantCulture)]);
Assert.Equal([10d, 20d], bySeries["m" + second.ToString(CultureInfo.InvariantCulture)]);
}
[Fact]
public async Task Anything_it_cannot_answer_is_a_400_never_a_500()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
using var app = new FrozenApp(fx.ConnectionString);
foreach (var query in new[]
{
"?period=forever",
"?bucket=hourly",
"?from=2024-02-01&to=2024-01-01",
"?scope=meters&ids=1,2,3,4,5,6,7",
"?scope=meter",
"?scope=category&id=1&metric=consumption",
"?metric=balance",
"?scope=meter&id=2147483647",
"?scope=type&id=32000",
"?scope=category&id=2147483647&metric=cost",
$"?scope=meter&id={meter}&from=1900-01-01&to=2299-12-31&bucket=day",
})
{
using var response = await app.Client.GetAsync(Url(AnalysisLinks.ExportPath + query));
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"{query} gave {(int)response.StatusCode}");
Assert.False(string.IsNullOrWhiteSpace(await response.Content.ReadAsStringAsync()), query);
}
// The refusal says what would work.
using var tooFine = await app.Client.GetAsync(Url($"{AnalysisLinks.ExportPath}?scope=meter&id={meter}&from=1900-01-01&to=2299-12-31&bucket=day"));
Assert.Contains("bucket=", await tooFine.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Fact]
public async Task The_page_defaults_export_without_any_key()
{
using var app = new FrozenApp(fx.ConnectionString);
using var response = await app.Client.GetAsync(Url(AnalysisLinks.ExportPath));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var text = (await response.Content.ReadAsStringAsync()).TrimStart('');
Assert.StartsWith(string.Join(',', AnalysisCsvWriter.Columns), text, StringComparison.Ordinal);
}
private static Uri Url(string relative) => new(relative, UriKind.Relative);
private static double Number(string cell) => double.Parse(cell, NumberStyles.Float, CultureInfo.InvariantCulture);
/// <summary>The data rows as column → cell (no quoted commas occur in these fixtures).</summary>
private static async Task<List<Dictionary<string, string>>> RowsAsync(HttpResponseMessage response)
{
var text = (await response.Content.ReadAsStringAsync()).TrimStart('');
var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
var header = lines[0].Split(',');
Assert.Equal(AnalysisCsvWriter.Columns, header);
return
[
.. lines.Skip(1).Select(line =>
{
var cells = line.Split(',');
Assert.Equal(header.Length, cells.Length);
return header.Zip(cells).ToDictionary(p => p.First, p => p.Second, StringComparer.Ordinal);
}),
];
}
/// <summary>The app on the frozen clock of <see cref="CostSandbox.Now"/>; disposes all it created.</summary>
private sealed class FrozenApp : IDisposable
{
private readonly MeterVaultAppFactory _root;
private readonly WebApplicationFactory<Program> _app;
public FrozenApp(string connectionString)
{
_root = new MeterVaultAppFactory(connectionString);
_app = _root.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(Now))));
Client = _app.CreateClient();
}
public HttpClient Client { get; }
public void Dispose()
{
Client.Dispose();
_app.Dispose();
_root.Dispose();
}
}
}
@@ -0,0 +1,165 @@
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;
/// <summary>
/// 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.
/// </summary>
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<int, string> { [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(" "));
}
}
@@ -0,0 +1,272 @@
using MeterVault.App.Analysis;
using MeterVault.App.AnalysisPage;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Options;
using MeterVault.Integration.Tests.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// What the Analysis page reads (brief §7.4): the portfolio cost is the bill the cost reader prices for the same range —
/// manual costs once, the same total whatever the bucket (D-36) — two historical years compare bucket by bucket from the
/// shared reader, and a selection that cannot be one chart (seven meters, a category of mixed units) reads nothing and
/// explains. Frozen clock of <see cref="CostSandbox.Now"/>, Berlin.
/// </summary>
[Collection("Timescale")]
public sealed class AnalysisPageLoaderTests(TimescaleFixture fx)
{
[Fact]
public async Task The_portfolio_cost_is_the_bill_with_manual_costs_once_whatever_the_bucket()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
await box.ManualCostAsync(D(2024, 2, 10), 40);
var view = await LoadAsync("/trends?from=2024-01-01&to=2024-02-29&bucket=month&compare=none");
Assert.Equal(AnalysisPageViewKind.Cost, view.Kind);
Assert.Equal(QueryScope.Portfolio, view.Selection.Scope);
var cost = Assert.Single(view.Costs);
Assert.Null(cost.Comparison);
// The very figure the cost reader gives the Overview for the same range.
var bill = await Readers().Costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Custom(D(2024, 1, 1), D(2024, 2, 29))) { Bucket = BucketSize.Month });
Assert.Equal(bill.Total.Cost, cost.Current.Total.Cost);
CostAssert.Priced(30 + 15 + 40, cost.Current.Total);
Assert.Equal(40, cost.Current.Total.Manual!.Value, 6);
Assert.Equal(45, cost.Current.Total.Usage!.Value, 6);
CostAssert.Cost(30, cost.Current.Buckets[0]);
CostAssert.Cost(15 + 40, cost.Current.Buckets[1]);
// One chart series and one table series, in the currency.
var chart = Assert.Single(view.Chart);
Assert.Equal("EUR", chart.Currency);
Assert.True(Assert.Single(view.Table).IsMoney);
// A finer or coarser bucket never changes the period total (D-36).
foreach (var bucket in new[] { "week", "day", "year" })
{
var other = await LoadAsync($"/trends?from=2024-01-01&to=2024-02-29&bucket={bucket}&compare=none");
Assert.Equal(cost.Current.Total.Cost!.Value, other.Costs.Single().Current.Total.Cost!.Value, 6);
Assert.Equal(40, other.Costs.Single().Current.Total.Manual!.Value, 6);
}
}
[Fact]
public async Task Two_historical_years_compare_bucket_by_bucket_with_the_overlay_and_the_table()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(
type, MeterMode.CumulativeCounter, D(2023, 1, 1), 80, 40, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 100, 50, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
var view = await LoadAsync($"/trends?scope=meter&id={meter}&from=2024-01-01&to=2024-12-31&compare=year:2023");
Assert.Equal(AnalysisPageViewKind.Quantity, view.Kind);
Assert.Equal(BucketSize.Month, view.Plan!.Size);
var series = Assert.Single(view.Series);
Assert.Equal(meter, series.MeterId);
Assert.Equal(350, series.Total.Value!.Value, 6);
// The comparison year, bucket by bucket, and the change over what both years cover.
Assert.True(view.Comparison!.IsApplicable);
Assert.Equal(12, view.Pairs!.Count);
Assert.Equal(D(2023, 1, 1), view.Pairs[0].Comparison.FirstDay);
var comparison = series.Comparison!;
Assert.Equal(80, comparison.Values[0].Value!.Value, 6);
Assert.Equal(40, comparison.Values[1].Value!.Value, 6);
Assert.Equal(220, comparison.Total.Value!.Value, 6);
Assert.True(view.Matched!.IsComparable);
Assert.Equal(130, comparison.Change.Absolute!.Value, 6);
// The overlay shares the meter's colour; the table carries the comparison and the meter's cost by its rule.
Assert.Contains(view.Chart, c => c.IsComparison && c.BaseKey == series.Key.Id);
var table = Assert.Single(view.Table);
Assert.NotNull(table.Comparison);
Assert.NotNull(table.Costs);
CostAssert.Priced(350 * 0.30, view.MeterCost!.Total);
// The previous year as a preset is the same comparison.
var previous = await LoadAsync($"/trends?scope=meter&id={meter}&from=2024-01-01&to=2024-12-31");
Assert.Equal(220, previous.Series.Single().Comparison!.Total.Value!.Value, 6);
}
[Fact]
public async Task Seven_meters_are_refused_before_anything_is_read()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var ids = new List<int>();
for (var i = 0; i < 7; i++)
{
ids.Add(await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 10 + i));
}
var (loader, reader, _) = Readers();
var options = await AnalysisPageOptions.LoadAsync(fx, reader);
var query = AnalysisQuery.Default(AnalysisDefaults.History).WithScope(QueryScope.ForMeters(ids));
var selection = AnalysisSelection.Resolve(query, options);
var view = await loader.LoadAsync(query, selection, options, Now);
Assert.Equal(AnalysisPageRefusal.TooManyMeters, view.Selection.Refusal);
Assert.True(view.IsRefused);
Assert.Null(view.Plan);
Assert.Empty(view.Series);
Assert.Empty(view.Chart);
// Six of them compare side by side, each its own series from the shared reader.
var six = query.WithScope(QueryScope.ForMeters(ids.Take(6)));
var shown = await loader.LoadAsync(six, AnalysisSelection.Resolve(six, options), options, Now);
Assert.False(shown.IsRefused);
Assert.Equal(ids.Take(6), shown.Series.Select(s => s.MeterId!.Value));
}
[Fact]
public async Task A_category_of_mixed_units_explains_while_one_of_a_single_unit_shows_its_meters_side_by_side()
{
await using var box = new CostSandbox(fx);
var power = await box.TypeAsync();
var water = await box.TypeAsync("m³");
var house = await box.MonthlyAsync(power, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
var car = await box.MonthlyAsync(power, MeterMode.CumulativeCounter, D(2024, 1, 1), 10, 20);
var tap = await box.MeterAsync(water, MeterMode.CumulativeCounter, "m³", installedAt: D(2024, 1, 1));
await box.MonthlyReadingsAsync(tap, D(2024, 1, 1), 5, 6);
await box.TypePriceAsync(power, 0.30, D(2023, 1, 1));
var mixed = await box.CategoryAsync($"mixed-{Guid.NewGuid():N}", 91, meters: [house, tap]);
var single = await box.CategoryAsync($"single-{Guid.NewGuid():N}", 92, meters: [house, car]);
const string Range = "&from=2024-01-01&to=2024-02-29&bucket=month&compare=none";
// kWh and m³: no quantity, and nothing read — the explanation names each group.
var refused = await LoadAsync($"/trends?scope=category&id={mixed}&metric=consumption{Range}");
Assert.Equal(AnalysisPageRefusal.CategoryMixed, refused.Selection.Refusal);
Assert.True(refused.IsRefused);
Assert.Null(refused.Plan);
Assert.Empty(refused.Series);
Assert.Equal(2, refused.Selection.Groups.Count);
// Its cost is always there.
var mixedCost = await LoadAsync($"/trends?scope=category&id={mixed}{Range}");
Assert.Equal(AnalysisPageViewKind.Cost, mixedCost.Kind);
var categoryBill = await Readers().Costs.ReadAsync(
new CostAnalysisRequest(CostScope.ForCategory(mixed), Custom(D(2024, 1, 1), D(2024, 2, 29))) { Bucket = BucketSize.Month });
Assert.Equal(categoryBill.Total.Cost, mixedCost.Costs.Single().Current.Total.Cost);
// Two kWh consumption meters: side by side, each exactly as the reader reads it on its own, never added up.
var shown = await LoadAsync($"/trends?scope=category&id={single}&metric=consumption{Range}");
Assert.False(shown.IsRefused);
Assert.Equal([house, car], shown.Series.Select(s => s.MeterId!.Value));
Assert.Equal(150, shown.Series[0].Total.Value!.Value, 6);
Assert.Equal(30, shown.Series[1].Total.Value!.Value, 6);
Assert.Equal(2, shown.Table.Count);
Assert.DoesNotContain(shown.Table, t => t.Total?.Value is 180);
}
[Fact]
public async Task An_energy_type_shows_its_measures_side_by_side_named_by_the_meters_they_count()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
var view = await LoadAsync($"/trends?scope=type&id={type}&from=2024-01-01&to=2024-02-29&bucket=month");
Assert.Equal(AnalysisMetric.Consumption, view.Selection.Metric);
var use = Assert.Single(view.Series);
Assert.Equal(Core.Analysis.Totals.TotalsMeasure.Use, use.Key.Measure);
Assert.Equal(150, use.Total.Value!.Value, 6);
Assert.Equal(use.Total.Value, view.Quantities!.MeasureFor(type, Core.Analysis.Totals.TotalsMeasure.Use)!.Total.Value);
// Nothing in the range but data before it: no data for this period, and the dates that have some.
var empty = await LoadAsync($"/trends?scope=type&id={type}&from=2025-01-01&to=2025-02-28");
Assert.True(empty.HasNoData);
Assert.Equal(D(2024, 1, 1), empty.Availability!.FirstDay);
}
[Fact]
public async Task A_source_without_data_is_named_in_the_figures_of_a_virtual_meter()
{
// Brief §4.3 "name the problem and affected source": the table and chart of /trends?scope=meter name the source a
// bucket misses by its name — as the attention list does — never "#id".
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2025, 1, 1), 100, 80);
var b = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2025, 1, 1), 150);
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
string bName;
await using (var db = fx.CreateContext())
{
bName = (await db.Meters.FindAsync(b))!.Name;
}
var view = await AnalysisUiTestData.In("en", () => LoadAsync($"/trends?scope=meter&id={sum}&from=2025-01-01&to=2025-02-28&bucket=month&compare=none"));
var table = Assert.Single(view.Table);
var february = table.Values[1];
Assert.Null(february.Value);
Assert.Contains(bName, february.Status.Detail, StringComparison.Ordinal);
Assert.DoesNotContain("#" + b.ToString(System.Globalization.CultureInfo.InvariantCulture), february.Status.Detail!, StringComparison.Ordinal);
Assert.Contains(bName, table.Total!.Status.Detail, StringComparison.Ordinal);
Assert.Contains(bName, view.Chart.Single().Values[1].Note, StringComparison.Ordinal);
}
[Fact]
public async Task A_type_of_monthly_meters_resolves_months_so_a_month_has_no_finer_bucket_to_open()
{
// D-51: drilling goes to the next finer bucket the data supports. A type's measure carries the resolution of the
// meters it counts — monthly here — so a month is not opened into 31 days of "only coarser data".
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), 100, 80, 90);
var (_, reader, _) = Readers();
var result = await reader.ReadAsync(
new AnalysisRequest(AnalysisScope.ForEnergyType(type), Custom(D(2025, 1, 1), D(2025, 3, 31))) { Bucket = BucketSize.Month });
Assert.Equal(ResolutionClass.Month, Assert.Single(result.Measures).Resolution);
var view = await LoadAsync($"/trends?scope=type&id={type}&metric=consumption&from=2025-01-01&to=2025-03-31&bucket=month");
Assert.Equal(ResolutionClass.Month, view.Resolution);
Assert.Null(AnalysisNavigation.DrillInto(view.Query, view.Plan!.Buckets[1], view.Resolution));
}
[Fact]
public async Task A_category_of_calculated_views_explains_itself_instead_of_no_data_yet()
{
// Brief §4.3: an explained result, not "nothing has been recorded yet" — its member has data and a cost of its own.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), 100, 80);
await box.TypePriceAsync(type, 0.30, D(2024, 1, 1));
var view = await box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var category = await box.CategoryAsync($"views-{Guid.NewGuid():N}", 95, meters: [view]);
var page = await LoadAsync($"/trends?scope=category&id={category}&metric=cost&from=2025-01-01&to=2025-02-28&bucket=month&compare=none");
Assert.False(page.HasNoData);
Assert.Contains(page.CostAttention, x => x.Kind == CostAttentionKind.CategoryPricesNothing && x.MeterIds.Contains(view));
}
private (AnalysisPageLoader Loader, AnalysisReader Reader, CostReader Costs) Readers()
{
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
var reader = new AnalysisReader(fx, options);
var costs = new CostReader(fx, reader, options);
return (new AnalysisPageLoader(reader, costs, new AnalysisPeriods(reader, costs)), reader, costs);
}
private async Task<AnalysisPageView> LoadAsync(string url)
{
var (loader, reader, _) = Readers();
var options = await AnalysisPageOptions.LoadAsync(fx, reader);
var query = AnalysisQuery.Parse(url, AnalysisDefaults.History);
return await loader.LoadAsync(query, AnalysisSelection.Resolve(query, options), options, Now);
}
}
@@ -0,0 +1,277 @@
using MeterVault.App.Analysis;
using MeterVault.App.AnalysisPage;
using MeterVault.Core.Analysis;
using MeterVault.Infrastructure.Analysis;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// 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.
/// </summary>
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));
}
}
@@ -0,0 +1,315 @@
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;
/// <summary>
/// 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.
/// </summary>
public sealed class AnalysisQueryTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>19 September 2026, 14:37 Berlin — the frozen now of the cost tests.</summary>
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<string, string>("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<ArgumentException>(() => query.WithPeriod(PeriodPreset.Custom));
Assert.Throws<ArgumentException>(() => query.WithCustomRange(new DateOnly(2025, 2, 1), new DateOnly(2025, 1, 1)));
Assert.Throws<ArgumentException>(() => query.WithComparison(new ComparisonRequest(ComparisonKind.Year)));
Assert.Throws<ArgumentException>(() => new AnalysisDefaults(PeriodPreset.Custom, BucketSize.Auto, ComparisonRequest.None));
Assert.Throws<ArgumentException>(() => QueryScope.ForMeters([]));
Assert.Throws<ArgumentOutOfRangeException>(() => 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The analysis table (brief §7.2) and the words beside every figure (D-08, D-14, brief §4.3): unknown is "—" and zero
/// is 0, the status is spoken, a change is stated only between complete figures and always with its absolute
/// difference, polarity decides the tone, cost columns name their price coverage. Pure; no database.
/// </summary>
public sealed class AnalysisTableModelTests
{
[Fact]
public void One_row_per_bucket_and_a_total_row_with_the_status_in_words() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = AnalysisTableSeries.ForSeries(Series(1, "Haus", [Available(120), Missing(), Available(0)], total: Partial(120)));
var table = AnalysisTableModel.Build(buckets, [series]);
Assert.Equal([AnalysisTableColumnKind.Value, AnalysisTableColumnKind.Status], table.Columns.Select(c => c.Kind));
Assert.Equal("Haus", table.Columns[0].Header);
Assert.Equal(["Jan", "Feb", "Mar", "Total"], table.Rows.Select(r => r.Label));
Assert.Equal(buckets[1], table.Rows[1].Bucket);
Assert.Null(table.Rows[3].Bucket);
Assert.True(table.Rows[3].IsTotal);
Assert.Equal(["120 kWh", "—", "0 kWh", "120 kWh"], table.Rows.Select(r => r.Cells[0].Text));
Assert.True(table.Rows[1].Cells[0].IsUnknown);
Assert.False(table.Rows[2].Cells[0].IsUnknown);
Assert.Equal("Complete · Measured", table.Rows[0].Cells[1].Text);
Assert.Equal("No data", table.Rows[1].Cells[1].Text);
Assert.Equal("No data covers this period", table.Rows[1].Cells[1].Secondary);
Assert.Equal("Partial · Measured", table.Rows[3].Cells[1].Text);
Assert.Equal([false, true, false, true], table.Rows.Select(r => r.IsQualified));
});
[Fact]
public void A_change_is_stated_only_between_complete_figures_and_names_the_compared_bucket() => In("en", () =>
{
var period = Range(D(2026, 1, 1), D(2026, 3, 31));
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
var pairs = ComparisonResolver.PairBuckets(
period, ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear)).Period!, buckets);
var matched = Change.Between(200, 190);
var reader = Series(
1, "Haus", [Available(120), Partial(50), Available(80)], total: Available(250),
comparison: Comparison([Available(100), Available(90), Missing()], Available(190), matched));
var table = AnalysisTableModel.Build(buckets, [AnalysisTableSeries.ForSeries(reader)], pairs);
Assert.Equal(
[AnalysisTableColumnKind.Value, AnalysisTableColumnKind.Status, AnalysisTableColumnKind.Comparison, AnalysisTableColumnKind.Change],
table.Columns.Select(c => c.Kind));
var comparison = table.Rows.Select(r => r.Cells[2]).ToList();
var change = table.Rows.Select(r => r.Cells[3]).ToList();
Assert.Equal(["100 kWh", "90 kWh", "—", "190 kWh"], comparison.Select(c => c.Text));
Assert.Equal(["Jan 2025", "Feb 2025", "Mar 2025", null], comparison.Select(c => c.Secondary));
// January: both complete. February: the current month is partial. March: nothing to compare with.
Assert.Equal("+20 kWh (+20.0 %)", change[0].Text);
Assert.Equal("mv-change-bad", change[0].CssClass);
Assert.Equal("—", change[1].Text);
Assert.True(change[1].IsUnknown);
Assert.Equal("—", change[2].Text);
// The total row states the reader's change over the matched coverage, not a sum of the rows.
Assert.Equal("+10 kWh (+5.3 %)", change[3].Text);
});
[Fact]
public void More_generation_is_good_news_and_a_net_result_is_neutral() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
var cmp = Comparison([Available(100)], Available(100), Change.Unavailable);
var generation = Series(1, "Solar", [Available(150)], kind: QuantityKind.Generation, comparison: cmp);
var net = Series(2, "Bilanz", [Available(150)], kind: QuantityKind.Net, comparison: cmp);
var table = AnalysisTableModel.Build(buckets, [AnalysisTableSeries.ForSeries(generation), AnalysisTableSeries.ForSeries(net)]);
Assert.Equal("mv-change-good", table.Rows[0].Cells[3].CssClass);
Assert.Equal("mv-change-neutral", table.Rows[0].Cells[7].CssClass);
// With two series, every column but the value names its series.
Assert.Equal("Solar", table.Columns[1].SubHeader);
Assert.Equal("Bilanz", table.Columns[4].Header);
});
[Fact]
public void Cost_columns_name_their_price_coverage() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var priced = Priced(buckets, 100, 0.25, priceFrom: D(2026, 2, 1)).Lines[0];
var series = AnalysisTableSeries.ForValues("m10", "Netz", "kWh", [Available(100), Available(100), Available(100)], Available(300))
.WithCosts(priced.Buckets, priced.Total, "EUR");
var table = AnalysisTableModel.Build(buckets, [series]);
Assert.Equal(AnalysisTableColumnKind.Cost, table.Columns[2].Kind);
Assert.Equal(["—", "25.00 €", "25.00 €", "50.00 €"], table.Rows.Select(r => r.Cells[2].Text));
Assert.Equal("Unavailable (tariff gap)", table.Rows[0].Cells[3].Text);
Assert.Equal("Priced", table.Rows[1].Cells[3].Text);
Assert.Equal("Partly priced", table.Rows[3].Cells[3].Text);
});
[Fact]
public void A_cost_series_compares_money_and_a_credit_is_neutral() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var current = Priced(buckets, 100, 0.25).Lines[0];
var previous = Priced(buckets, 80, 0.25).Lines[0];
var table = AnalysisTableModel.Build(
buckets,
[AnalysisTableSeries.ForCosts("cost", "Strom", "EUR", current.Buckets, current.Total).WithComparisonCosts(previous.Buckets, previous.Total, "EUR")]);
Assert.Equal(["25.00 €", "25.00 €", "50.00 €"], table.Rows.Select(r => r.Cells[0].Text));
Assert.Equal("+5.00 € (+25.0 %)", table.Rows[0].Cells[3].Text);
Assert.Equal("mv-change-bad", table.Rows[0].Cells[3].CssClass);
Assert.Equal("+10.00 € (+25.0 %)", table.Rows[2].Cells[3].Text);
});
[Theory]
[InlineData(120, 100, "en", "20 kWh more (+20.0 %)")]
[InlineData(80, 100, "en", "20 kWh less (-20.0 %)")]
[InlineData(50, 0, "en", "50 kWh more (percentage not applicable)")]
[InlineData(-40, -50, "en", "10 kWh more (percentage not applicable)")]
[InlineData(100, 100, "en", "No change")]
[InlineData(120, 100, "de", "20 kWh mehr (+20,0 %)")]
[InlineData(50, 0, "de", "50 kWh mehr (keine Prozentangabe möglich)")]
public void A_change_reads_as_words_with_its_absolute_difference(double current, double previous, string culture, string expected) =>
Assert.Equal(expected, In(culture, () => ChangeDisplay.Words(Change.Between(current, previous), v => Format.Quantity(v, "kWh"))));
[Fact]
public void An_unknown_side_is_no_comparison_not_a_hundred_percent_drop()
{
Assert.Equal("No comparison", In("en", () => ChangeDisplay.Words(Change.Between(null, 100), v => Format.Number(v))));
Assert.Equal("Kein Vergleich", In("de", () => ChangeDisplay.Words(Change.Unavailable, v => Format.Number(v))));
}
[Fact]
public void The_tone_follows_the_metric_not_the_sign()
{
var up = Change.Between(120, 100);
var down = Change.Between(80, 100);
Assert.Equal(ChangeTone.Bad, ChangeDisplay.Tone(up, ChangePolarities.For(QuantityKind.Consumption)));
Assert.Equal(ChangeTone.Good, ChangeDisplay.Tone(down, ChangePolarities.For(AnalysisMetric.Cost)));
Assert.Equal(ChangeTone.Good, ChangeDisplay.Tone(up, ChangePolarities.For(QuantityKind.Generation)));
Assert.Equal(ChangeTone.Bad, ChangeDisplay.Tone(down, ChangePolarities.For(AnalysisMetric.Export)));
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(up, ChangePolarities.For(QuantityKind.Net)));
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(up, ChangePolarities.For(AnalysisMetric.Balance)));
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(Change.Between(100, 100), ChangePolarity.HigherIsWorse));
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(Change.Unavailable, ChangePolarity.HigherIsWorse));
// A credit on either side leaves "more" and "less" without a settled meaning.
Assert.Equal(ChangePolarity.Neutral, ChangePolarities.ForCost(-5, 10));
Assert.Equal(ChangePolarity.Neutral, ChangePolarities.ForCost(5, -10));
Assert.Equal(ChangePolarity.HigherIsWorse, ChangePolarities.ForCost(5, 10));
Assert.Equal("mv-change-bad", ChangeDisplay.CssClass(ChangeTone.Bad));
}
[Fact]
public void A_derived_value_names_the_source_it_misses() => In("en", () =>
{
var value = new BucketValue(null, BucketStatus.Missing, Provenance.Derived, ValueIssue.MissingSource, null, [9, 5]);
var names = new Dictionary<int, string> { [5] = "Solar 2", [9] = "Summe Solar" };
var status = FigureText.Of(value, id => names.GetValueOrDefault(id));
Assert.False(status.IsKnown);
Assert.True(status.IsQualified);
Assert.Equal("No data · Calculated", status.Summary);
Assert.Equal("A source meter has no data here (Solar 2)", status.Detail);
Assert.Equal("A source meter has no data here (#5)", FigureText.Of(value).Detail);
});
[Fact]
public void An_opening_balance_or_an_estimate_is_qualified_but_an_estimate_stays_complete()
{
var estimate = FigureText.Of(Available(5, Provenance.Imported | Provenance.Estimated));
Assert.True(estimate.IsComplete);
Assert.True(estimate.IsQualified);
var plain = FigureText.Of(Available(5));
Assert.True(plain.IsComplete);
Assert.False(plain.IsQualified);
var opening = FigureText.Of(new BucketValue(5, BucketStatus.Partial, Provenance.OpeningBalance, ValueIssue.OpeningBalance));
Assert.False(opening.IsComplete);
Assert.True(opening.IsQualified);
}
[Fact]
public void A_cost_over_incomplete_quantities_says_so() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
var none = FigureText.Of(Priced(buckets, 100, null).Total);
Assert.False(none.IsKnown);
Assert.Equal("Not priced (no tariff)", none.Status);
var priced = FigureText.Of(Priced(buckets, 100, 0.25).Total);
Assert.True(priced.IsKnown);
Assert.True(priced.IsComplete);
Assert.False(priced.IsQualified);
});
[Fact]
public void A_cost_with_nothing_booked_reads_no_data_never_priced_beside_a_dash() => In("en", () =>
{
// A month with nothing to bill and nothing missing (a manual-cost-only instance between its costs) is unknown —
// the engine keeps it null (SeededBillTests) — so it is not "Priced" and not complete (§4.3: one meaning).
var nothing = FigureText.Of(CostAmount.Empty);
Assert.False(nothing.IsKnown);
Assert.False(nothing.IsComplete);
Assert.True(nothing.IsQualified);
Assert.Equal("No data", nothing.Status);
Assert.True(FigureText.IsNothingBooked(CostAmount.Empty));
var row = TableFigure.Of(CostAmount.Empty, "EUR");
Assert.Equal("—", row.Text);
Assert.Equal("No data", row.Status.Status);
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
Assert.False(FigureText.IsNothingBooked(Priced(buckets, 100, 0.25).Total));
Assert.False(FigureText.IsNothingBooked(Priced(buckets, 100, null).Total));
});
}
@@ -0,0 +1,103 @@
using System.Globalization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// Builders for the pure tests of the shared analysis components: real buckets from the period resolver and the bucket
/// planner (Berlin, frozen clock), values, reader series, priced cost figures, and a culture scope — the machine running
/// the tests may be German, and the components format for the reader.
/// </summary>
internal static class AnalysisUiTestData
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>Long after every range the tests use, so custom ranges resolve as complete.</summary>
public static readonly DateTimeOffset FarFuture = new(2040, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>19 September 2026, 14:37 Berlin.</summary>
public static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime();
public static DateOnly D(int year, int month, int day) => new(year, month, day);
public static ResolvedPeriod Range(DateOnly first, DateOnly last) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, FarFuture, Berlin);
public static IReadOnlyList<AnalysisBucket> Buckets(DateOnly first, DateOnly last, BucketSize size = BucketSize.Month) =>
BucketPlanner.Plan(Range(first, last), size).Buckets;
public static BucketValue Available(double value, Provenance provenance = Provenance.Measured) => BucketValue.Available(value, provenance);
public static BucketValue Partial(double value) =>
new(value, BucketStatus.Partial, Provenance.Measured, ValueIssue.PartialCoverage);
public static BucketValue Missing() => BucketValue.Missing();
/// <summary>A physical meter's series in kWh with its total and, optionally, a comparison.</summary>
public static AnalysisSeries Series(
int meterId,
string name,
IReadOnlyList<BucketValue> values,
BucketValue? total = null,
QuantityKind kind = QuantityKind.Consumption,
SeriesComparison? comparison = null) =>
new(SeriesKey.ForMeter(meterId, 1, "kWh"), name, SeriesBasis.Physical, kind, "kWh", values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
{
Comparison = comparison,
};
public static SeriesComparison Comparison(IReadOnlyList<BucketValue> values, BucketValue total, Change change) =>
new(values, total, MatchedCoverageResult.NotComparable, null, null, change);
/// <summary>
/// One energy type's bill line priced over <paramref name="buckets"/> at <paramref name="price"/> €/kWh from
/// <paramref name="priceFrom"/> (no tariff at all when null), with <paramref name="amount"/> kWh per month.
/// </summary>
public static CostResult Priced(IReadOnlyList<AnalysisBucket> buckets, double amount, double? price, DateOnly? priceFrom = null)
{
Tariff[] tariffs = price is { } value
? [new Tariff
{
Id = 1,
ScopeType = TariffScope.EnergyType,
ScopeId = 1,
Component = TariffComponent.UnitPrice,
Value = value,
Unit = "EUR/kWh",
ValidFrom = priceFrom ?? D(2000, 1, 1),
}]
: [];
var quantities = CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, amount)).ToList();
var line = new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities);
return CostCalculator.Calculate(new CostRequest(buckets, D(2039, 12, 31), TariffBook.Create(tariffs, "EUR"), [line]));
}
/// <summary>Runs <paramref name="body"/> with <paramref name="culture"/> as the formatting and the UI culture.</summary>
public static T In<T>(string culture, Func<T> body)
{
var format = CultureInfo.CurrentCulture;
var ui = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
return body();
}
finally
{
CultureInfo.CurrentCulture = format;
CultureInfo.CurrentUICulture = ui;
}
}
public static void In(string culture, Action body) => In(culture, () =>
{
body();
return 0;
});
}
@@ -0,0 +1,160 @@
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Domain;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The link helpers (D-47, D-52, brief §3.1): stable meter tab keys with their compatibility mapping, and period-carrying
/// links to the meter, energy type, analysis, specialized and export pages and the tariff editor. Pure; no database.
/// </summary>
public sealed class AppLinkTests
{
[Theory]
[InlineData(null, MeterMode.CumulativeCounter, "analysis")]
[InlineData("", MeterMode.CumulativeCounter, "analysis")]
[InlineData("readings", MeterMode.CumulativeCounter, "readings")]
[InlineData(" EVENTS ", MeterMode.CumulativeCounter, "events")]
[InlineData("consumption", MeterMode.CumulativeCounter, "normalized")]
[InlineData("normalized", MeterMode.GenerationCounter, "normalized")]
[InlineData("tariffs", MeterMode.RuntimeCounter, "tariffs")]
[InlineData("sources", MeterMode.InstantRate, "sources")]
[InlineData("calculation", MeterMode.CumulativeCounter, "analysis")]
[InlineData("nonsense", MeterMode.DirectDelta, "analysis")]
[InlineData("readings", MeterMode.ConsumableBalance, "readings")]
[InlineData("events", MeterMode.ConsumableBalance, "events")]
[InlineData("sources", MeterMode.Virtual, "calculation")]
[InlineData("readings", MeterMode.Virtual, "analysis")]
[InlineData("consumption", MeterMode.Virtual, "analysis")]
[InlineData("normalized", MeterMode.Virtual, "analysis")]
[InlineData("events", MeterMode.Virtual, "events")]
[InlineData("Calculation", MeterMode.Virtual, "calculation")]
public void Every_tab_key_opens_a_tab_the_meter_shows(string? requested, MeterMode mode, string expected)
{
var resolved = MeterLinks.ResolveTab(requested, mode);
Assert.Equal(expected, resolved);
Assert.Contains(resolved, MeterLinks.VisibleTabs(mode));
}
[Fact]
public void Tabs_follow_the_mode()
{
Assert.Equal(["analysis", "readings", "normalized", "events", "tariffs", "sources"], MeterLinks.VisibleTabs(MeterMode.CumulativeCounter));
Assert.Equal(["analysis", "readings", "normalized", "events", "tariffs", "sources"], MeterLinks.VisibleTabs(MeterMode.ConsumableBalance));
// Calculation replaces Sources, Readings goes, Events keeps Note (D-31).
Assert.Equal(["analysis", "events", "tariffs", "calculation"], MeterLinks.VisibleTabs(MeterMode.Virtual));
foreach (var mode in Enum.GetValues<MeterMode>())
{
var tabs = MeterLinks.VisibleTabs(mode);
Assert.Equal(MeterLinks.TabAnalysis, tabs[0]);
for (var i = 0; i < tabs.Count; i++)
{
Assert.Equal(i, MeterLinks.PanelIndex(tabs[i], mode));
}
}
Assert.Equal(3, MeterLinks.PanelIndex("sources", MeterMode.Virtual));
Assert.Equal(0, MeterLinks.PanelIndex("readings", MeterMode.Virtual));
Assert.Equal(2, MeterLinks.PanelIndex("consumption", MeterMode.CumulativeCounter));
}
[Fact]
public void Meter_links_carry_the_period_after_their_own_keys()
{
Assert.Equal("/meters/7?tab=analysis", MeterLinks.Analysis(7));
Assert.Equal("/meters/7?tab=analysis", MeterLinks.Analysis(7, AnalysisQuery.Default(AnalysisDefaults.History)));
Assert.Equal("/meters/7?tab=analysis&period=mtd", MeterLinks.Analysis(7, AnalysisQuery.Default(AnalysisDefaults.Overview)));
var query = AnalysisQuery.Parse("?scope=type&id=2&from=2025-01-01&to=2025-01-31&bucket=day&metric=generation", AnalysisDefaults.History);
Assert.Equal(
"/meters/7?tab=analysis&metric=generation&from=2025-01-01&to=2025-01-31&bucket=day",
MeterLinks.Analysis(7, query));
Assert.Equal(
"/meters/7?tab=normalized&metric=generation&from=2025-01-01&to=2025-01-31&bucket=day",
MeterLinks.Detail(7, MeterLinks.TabNormalized, null, query));
Assert.Equal("/meters/7?tab=events&action=note", MeterLinks.Detail(7, MeterLinks.TabEvents, "note", null));
// The existing addresses are untouched.
Assert.Equal("/meters/7?tab=readings&action=reading", MeterLinks.QuickEntry(7, MeterMode.CumulativeCounter));
Assert.Equal("/meters/7", MeterLinks.Detail(7));
}
[Fact]
public void Analysis_page_links_name_scope_and_metric()
{
var ytd = AnalysisQuery.Default(AnalysisDefaults.History).WithPeriod(PeriodPreset.YearToDate);
Assert.Equal("/trends?scope=type&id=3&metric=cost&period=ytd", AnalysisLinks.Analysis(QueryScope.ForEnergyType(3), AnalysisMetric.Cost, ytd));
Assert.Equal("/trends", AnalysisLinks.Analysis(QueryScope.Portfolio));
Assert.Equal("/trends?scope=meters&ids=4,9", AnalysisLinks.Analysis(QueryScope.ForMeters([4, 9])));
Assert.Equal(
"/trends?scope=category&id=2&metric=generation&period=ytd",
AnalysisLinks.Analysis(QueryScope.ForCategory(2), query: ytd.WithMetric(AnalysisMetric.Generation)));
}
[Fact]
public void Energy_type_and_specialized_links_carry_the_period()
{
var mtd = AnalysisQuery.Default(AnalysisDefaults.Overview);
Assert.Equal("/energy/3", AnalysisLinks.EnergyType(3));
Assert.Equal("/energy/3", AnalysisLinks.EnergyType(3, AnalysisLinks.EnergyTabOverview));
Assert.Equal("/energy/3?tab=history&period=mtd", AnalysisLinks.EnergyType(3, "History", mtd));
Assert.Equal("/energy/3?period=mtd", AnalysisLinks.EnergyType(3, "bogus", mtd));
Assert.Equal("/solar?period=mtd", AnalysisLinks.Solar(mtd));
Assert.Equal("/consumables", AnalysisLinks.Consumables());
Assert.Equal("/", AnalysisLinks.Overview(mtd));
Assert.Equal("/?period=12m", AnalysisLinks.Overview(AnalysisQuery.Default(AnalysisDefaults.History)));
Assert.Equal("overview", AnalysisLinks.ResolveEnergyTab(null));
Assert.Equal("flow", AnalysisLinks.ResolveEnergyTab(" FLOW"));
Assert.Equal(3, AnalysisLinks.EnergyTabIndex("meters"));
Assert.Equal(0, AnalysisLinks.EnergyTabIndex("sankey"));
}
[Fact]
public void The_export_link_writes_the_scope_it_shows()
{
var meterPage = AnalysisQuery.Parse("/meters/5?tab=analysis", AnalysisDefaults.History.ForScope(QueryScope.ForMeter(5)));
Assert.Equal("/export/analysis.csv?scope=meter&id=5", AnalysisLinks.Export(meterPage));
var overview = AnalysisQuery.Default(AnalysisDefaults.Overview).WithMetric(AnalysisMetric.Cost);
Assert.Equal("/export/analysis.csv?metric=cost&period=mtd", AnalysisLinks.Export(overview));
}
[Fact]
public void Tariff_links_prefill_a_new_tariff()
{
Assert.Equal(
"/admin/tariffs?scope=meter&id=12&component=unit-price&from=2024-01-01&action=new",
TariffLinks.New(TariffScope.Meter, 12, TariffComponent.UnitPrice, new DateOnly(2024, 1, 15)));
Assert.Equal(
"/admin/tariffs?scope=type&id=3&component=feed-in&from=2026-02-01&action=new",
TariffLinks.New(TariffScope.EnergyType, 3, TariffComponent.FeedIn, new DateOnly(2026, 2, 1)));
Assert.Equal(
"/admin/tariffs?scope=global&component=base-price&from=2025-06-01&action=new",
TariffLinks.New(TariffScope.Global, 99, TariffComponent.BasePrice, new DateOnly(2025, 6, 30)));
foreach (var scope in Enum.GetValues<TariffScope>())
{
Assert.True(TariffLinks.TryParseScope(TariffLinks.ScopeToken(scope), out var parsed));
Assert.Equal(scope, parsed);
Assert.True(TariffLinks.TryParseScope(scope.ToString().ToUpperInvariant(), out parsed));
Assert.Equal(scope, parsed);
}
foreach (var component in Enum.GetValues<TariffComponent>())
{
Assert.True(TariffLinks.TryParseComponent(TariffLinks.ComponentToken(component), out var parsed));
Assert.Equal(component, parsed);
Assert.True(TariffLinks.TryParseComponent(component.ToString(), out parsed));
Assert.Equal(component, parsed);
}
Assert.False(TariffLinks.TryParseComponent("price", out _));
Assert.False(TariffLinks.TryParseScope(null, out _));
}
}
@@ -0,0 +1,226 @@
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// Attention items (D-52, D-53): each reader code becomes a localized one-liner with its one targeted action — the
/// prefilled tariff editor for a missing price, the Calculation tab for a definition to fix, the Sources tab for a stale
/// source, Normalized data around rows after now, the energy type's Meters tab for a possible overlap — carrying the
/// period; unknown kinds degrade to their wording. Pure; no database.
/// </summary>
public sealed class AttentionItemsTests
{
private static readonly AttentionNames Names = new(
new Dictionary<int, string> { [1] = "Haus", [2] = "Netz", [3] = "Solar 1", [5] = "Auto", [9] = "Summe Solar" },
new Dictionary<int, string> { [1] = "Strom" });
/// <summary>A meter page on "this year to date": links carry the period.</summary>
private static readonly AnalysisQuery Ytd = AnalysisQuery.Default(AnalysisDefaults.History).WithPeriod(PeriodPreset.YearToDate);
[Fact]
public void A_missing_price_opens_the_tariff_editor_for_its_scope_component_and_first_month() => In("en", () =>
{
var gap = new MissingPrice(TariffComponent.UnitPrice, CostStatus.PriceGap, TariffScope.EnergyType, 1, null, D(2024, 1, 1), D(2024, 3, 1));
var item = AttentionItems.ForCost(new CostAttention(CostAttentionKind.MissingPrice, null) { Price = gap }, Names);
Assert.Equal(AttentionSeverity.Warning, item.Severity);
Assert.Equal("Strom: Unit price missing from Jan 2024.", item.Text);
Assert.Equal("Add tariff", item.ActionText);
Assert.Equal("/admin/tariffs?scope=type&id=1&component=unit-price&from=2024-01-01&action=new", item.ActionHref);
});
[Fact]
public void Missing_prices_are_worded_by_reason_and_an_absent_credit_is_only_a_note() => In("de", () =>
{
var none = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, 1, 2, D(2026, 1, 1), D(2026, 9, 1)), Names);
Assert.Equal("Netz: Arbeitspreis nicht hinterlegt.", none.Text);
Assert.Equal("Tarif anlegen", none.ActionText);
var mismatch = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.Meter, 2, 2, D(2025, 2, 1), D(2025, 2, 1), TariffId: 7), Names);
Assert.Equal("Netz: Arbeitspreis passt ab Feb 2025 nicht zur Einheit des Zählers.", mismatch.Text);
Assert.Equal("Tarif korrigieren", mismatch.ActionText);
Assert.Equal("/admin/tariffs?scope=meter&id=2&component=unit-price&from=2025-02-01&action=new", mismatch.ActionHref);
var credit = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.FeedIn, CostStatus.PriceGap, TariffScope.EnergyType, 1, 4, D(2026, 3, 1), D(2026, 3, 1)), Names);
Assert.Equal(AttentionSeverity.Info, credit.Severity);
Assert.Contains("die Gutschrift ist nicht enthalten", credit.Text, StringComparison.Ordinal);
Assert.Contains("Zähler #4", credit.Text, StringComparison.Ordinal);
var global = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, TariffScope.Global, null, null, D(2026, 1, 1), D(2026, 1, 1)), Names);
Assert.StartsWith("Alle Energiearten: Grundpreis fehlt ab", global.Text, StringComparison.Ordinal);
Assert.Equal("/admin/tariffs?scope=global&component=base-price&from=2026-01-01&action=new", global.ActionHref);
});
[Fact]
public void A_currency_or_base_price_mismatch_says_what_does_not_fit_not_the_meters_unit()
{
// D-37: an EUR tariff in a USD instance, or a base price quoted per a period that cannot be accrued, is not a
// problem of the meter's unit — the item says what to fix.
var currency = new MissingPrice(
TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.Meter, 2, 2, D(2025, 1, 1), D(2025, 1, 1), TariffId: 7, Issue: TariffUnitIssue.CurrencyMismatch);
var basePrice = new MissingPrice(
TariffComponent.BasePrice, CostStatus.UnitMismatch, TariffScope.Meter, 2, 2, D(2025, 1, 1), D(2025, 1, 1), TariffId: 8, Issue: TariffUnitIssue.UnsupportedPeriod);
In("en", () =>
{
Assert.Equal("Netz: Unit price is quoted in another currency than this instance uses, from Jan 2025.", AttentionItems.MissingPrice(currency, Names).Text);
var item = AttentionItems.MissingPrice(basePrice, Names);
Assert.Equal("Netz: the unit of Base price cannot be used from Jan 2025 a base price is quoted per day, month or year.", item.Text);
Assert.DoesNotContain("meter's unit", item.Text, StringComparison.Ordinal);
Assert.Equal("Fix tariff", item.ActionText);
});
In("de", () =>
{
Assert.Equal("Netz: Arbeitspreis ist ab Jan 2025 in einer anderen Währung angegeben, als diese Instanz verwendet.", AttentionItems.MissingPrice(currency, Names).Text);
Assert.DoesNotContain("Einheit des Zählers", AttentionItems.MissingPrice(basePrice, Names).Text, StringComparison.Ordinal);
});
}
[Fact]
public void A_category_whose_members_price_nothing_names_them_and_leads_to_the_categories()
{
var names = new AttentionNames(
new Dictionary<int, string> { [9] = "Summe Solar", [3] = "Solar 1" }, null, new Dictionary<int, string> { [4] = "PV view" });
var attention = new CostAttention(CostAttentionKind.CategoryPricesNothing, 9) { CategoryId = 4, MeterIds = [9, 3] };
In("en", () =>
{
var item = AttentionItems.ForCost(attention, names);
Assert.Equal(AttentionSeverity.Info, item.Severity);
Assert.StartsWith("PV view: Summe Solar, Solar 1 add nothing to this category's cost", item.Text, StringComparison.Ordinal);
Assert.Equal("Edit categories", item.ActionText);
Assert.Equal("/admin/categories", item.ActionHref);
});
In("de", () => Assert.StartsWith(
"PV view: Summe Solar, Solar 1 tragen nichts zu den Kosten dieser Kategorie bei", AttentionItems.ForCost(attention, names).Text, StringComparison.Ordinal));
}
[Theory]
[InlineData(AnalysisProblemKind.InvalidDefinition, AttentionSeverity.Error, "Edit calculation")]
[InlineData(AnalysisProblemKind.MalformedDefinition, AttentionSeverity.Error, "Edit calculation")]
[InlineData(AnalysisProblemKind.LegacyNeedsConfiguration, AttentionSeverity.Error, "Set up calculation")]
[InlineData(AnalysisProblemKind.LegacyDefinition, AttentionSeverity.Info, "Confirm calculation")]
public void A_calculation_to_fix_opens_the_meters_calculation_tab_with_the_period(AnalysisProblemKind kind, AttentionSeverity severity, string action) => In("en", () =>
{
var item = AttentionItems.ForProblem(new AnalysisProblem(kind, 9), Names, Ytd);
Assert.Equal(severity, item.Severity);
Assert.StartsWith("Summe Solar", item.Text, StringComparison.Ordinal);
Assert.Equal(action, item.ActionText);
Assert.Equal("/meters/9?tab=calculation&period=ytd", item.ActionHref);
});
[Fact]
public void Sources_rows_after_now_overlaps_and_conflicts_each_get_their_own_place() => In("en", () =>
{
var stale = AttentionItems.ForProblem(new AnalysisProblem(AnalysisProblemKind.StaleSource, 3), Names);
Assert.Equal("Solar 1: the live source has stopped delivering.", stale.Text);
Assert.Equal("/meters/3?tab=sources", stale.ActionHref);
var afterNow = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.RecordedAfterNow, 5) { AfterNow = new RecordedAfterNow(5, 1, 42, D(2026, 9, 30), D(2026, 9, 30)) },
Names);
Assert.Equal("Auto: values dated after now (Sep 30, 2026) are not counted yet.", afterNow.Text);
Assert.Equal("/meters/5?tab=normalized&from=2026-09-30&to=2026-09-30", afterNow.ActionHref);
var overlap = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.PossibleOverlap, 2)
{
MeterIds = [1],
Hint = new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, 1, 2, 1),
},
Names,
Ytd);
Assert.Equal("Netz and Haus are not linked, so they may count the same energy twice.", overlap.Text);
Assert.Equal("Manage meters", overlap.ActionText);
Assert.Equal("/energy/1?tab=meters&period=ytd", overlap.ActionHref);
var conflict = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.TotalsProblem, 2)
{
MeterIds = [1],
Totals = new TotalsProblem(TotalsProblemKind.DuplicateRole, 2, 1, MeterRole.GridImport),
},
Names);
Assert.Equal(
"Netz and Haus: the totals configuration contradicts itself. Two meters hold the same role at the same time; the one created first keeps it (Grid import).",
conflict.Text);
Assert.Equal("/meters/2?tab=analysis&action=edit", conflict.ActionHref);
var pending = AttentionItems.ForProblem(new AnalysisProblem(AnalysisProblemKind.AnalysisPending, 1), Names);
Assert.Equal(AttentionSeverity.Info, pending.Severity);
Assert.Null(pending.ActionHref);
Assert.Null(pending.ActionText);
});
[Fact]
public void An_unknown_kind_degrades_to_its_wording_without_an_action() => In("en", () =>
{
var item = AttentionItems.ForProblem(new AnalysisProblem((AnalysisProblemKind)999, 1), Names);
Assert.Equal("Haus: 999", item.Text);
Assert.Null(item.ActionHref);
var cost = AttentionItems.ForCost(new CostAttention((CostAttentionKind)999, null), Names);
Assert.Equal("999", cost.Text);
Assert.Null(cost.ActionHref);
});
[Fact]
public void Items_collapse_duplicates_and_put_errors_first() => In("en", () =>
{
AnalysisProblem[] problems =
[
new(AnalysisProblemKind.AnalysisPending, 1),
new(AnalysisProblemKind.StaleSource, 3),
new(AnalysisProblemKind.InvalidDefinition, 9),
new(AnalysisProblemKind.StaleSource, 3),
];
// The cost reader repeats the quantity reader's problems; they appear once.
var items = AttentionItems.Build(problems, [new CostAttention(CostAttentionKind.ManualCostAfterToday, null) { ManualCostIds = [4, 6] }], Names);
Assert.Equal(
[AttentionSeverity.Error, AttentionSeverity.Warning, AttentionSeverity.Info, AttentionSeverity.Info],
items.Select(i => i.Severity));
Assert.Equal("Summe Solar: the calculation is invalid, so no values can be shown.", items[0].Text);
Assert.Equal("Manual costs dated after today are not counted yet (2).", items[3].Text);
Assert.Empty(AttentionItems.Build(null, null, Names));
});
[Fact]
public void Names_come_from_the_results_and_fall_back_to_the_id() => In("de", () =>
{
var names = new AttentionNames();
Assert.Equal("Zähler #12", names.Meter(12));
Assert.Equal("Energieart #3", names.EnergyType(3));
Assert.Null(names.MeterOrNull(12));
var nested = new SeriesContribution(7, "Solar 2", false, 1, [], [], Available(1), 1, [9, 7], []);
var virtualSeries = Series(9, "Summe Solar", [Available(1)]) with { Contributions = [new SeriesContribution(3, "Solar 1", true, 1, [], [], Available(1), 1, [9, 3], [nested])] };
var result = new AnalysisResult(
new AnalysisRequest(AnalysisScope.ForMeter(9), Range(D(2026, 1, 1), D(2026, 1, 31))),
BucketPlanner.Plan(Range(D(2026, 1, 1), D(2026, 1, 31)), BucketSize.Month),
[virtualSeries],
[],
ScopeAvailability.None,
[]);
var fromResult = AttentionNames.From(result);
Assert.Equal("Summe Solar", fromResult.Meter(9));
Assert.Equal("Solar 1", fromResult.Meter(3));
Assert.Equal("Solar 2", fromResult.Meter(7));
});
}
@@ -0,0 +1,111 @@
using MeterVault.App.Analysis;
using MeterVault.App.AnalysisPage;
using MeterVault.App.Energy;
using MeterVault.App.MeterDetails;
using MeterVault.Core.Analysis;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Options;
using MeterVault.Integration.Tests.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// Matching scopes and periods reconcile across pages (brief §10 Phase 4 exit, §12): the Overview's type card, the energy
/// type page, the Analysis page and the billed meter's own page state the same cost change for the same period — over
/// the buckets both periods cover completely (D-07) — and the same standing charges. Frozen clock of 19 September 2026.
/// </summary>
[Collection("Timescale")]
public sealed class CostConsistencyTests(TimescaleFixture fx)
{
private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear);
[Fact]
public async Task A_partial_period_states_the_same_matched_cost_change_on_every_page()
{
// Like the seed: data from Sep 2024 to May 2026, so "last 12 months" (Oct 2025 Sep 2026) is partial. Its cost
// change is measured over Oct May, where both years are complete, and every page says the same.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 9, 1), [.. Enumerable.Range(0, 21).Select(i => 100d + (i * 5))]);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
var (reader, costs, periods) = Readers();
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.Last12Months), BucketSize.Auto, PreviousYear);
var onOverview = overview.Types.Single(t => t.Type.Id == type).CostChange;
Assert.Equal(CostChangeBasis.MatchedBuckets, onOverview.Basis);
Assert.NotNull(onOverview.Change.Absolute);
var energyQuery = AnalysisQuery.Parse("?period=12m", AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type)));
var energy = await new EnergyAnalysisLoader(fx, periods, reader, costs, new FlowService(fx, Options())).LoadAsync(type, energyQuery, Now);
var analysisQuery = AnalysisQuery.Parse($"/trends?scope=type&id={type}&metric=cost&period=12m", AnalysisDefaults.History);
var options = await AnalysisPageOptions.LoadAsync(fx, reader);
var analysis = await new AnalysisPageLoader(reader, costs, periods)
.LoadAsync(analysisQuery, AnalysisSelection.Resolve(analysisQuery, options), options, Now);
var meterQuery = AnalysisQuery.Parse("?period=12m", MeterAnalysisLoader.DefaultsFor(meter));
var meterView = await new MeterAnalysisLoader(periods, reader, costs, new MeterDetailService(fx, Options(), reader)).LoadAsync(meter, meterQuery, Now);
foreach (var (page, change) in new[]
{
("energy", energy.CostChange),
("analysis", analysis.Costs.Single().CostChange),
("meter", meterView.CostChange),
})
{
Assert.True(onOverview.Basis == change.Basis, page);
Assert.Equal(onOverview.Change.Absolute!.Value, change.Change.Absolute!.Value, 6);
Assert.Equal(onOverview.Current!.Value, change.Current!.Value, 6);
}
// The Analysis table's total row states it too, rather than "—".
Assert.Equal(onOverview.Change.Absolute!.Value, analysis.Table.Single().TotalChange!.Absolute!.Value, 6);
}
[Fact]
public async Task The_energy_card_counts_the_meter_fees_on_bill_lines_like_every_other_card()
{
// D-40: a meter fee stays on its meter's bill line. 10 €/month on the type and 3 €/month on the billed meter over
// 2024 are 156 € of standing charges — on the energy card as on the Analysis and Overview cards, never 120.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), [.. Enumerable.Repeat(100d, 12)]);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
await box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 10, "EUR/month", D(2023, 1, 1));
await box.TariffAsync(TariffScope.Meter, meter, TariffComponent.BasePrice, 3, "EUR/month", D(2023, 1, 1));
var (reader, costs, periods) = Readers();
var query = AnalysisQuery.Parse("?from=2024-01-01&to=2024-12-31", AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type)));
var energy = await new EnergyAnalysisLoader(fx, periods, reader, costs, new FlowService(fx, Options())).LoadAsync(type, query, Now);
Assert.Equal(156, energy.StandingCharge!.Value, 6);
var analysisQuery = AnalysisQuery.Parse($"/trends?scope=type&id={type}&metric=cost&from=2024-01-01&to=2024-12-31", AnalysisDefaults.History);
var options = await AnalysisPageOptions.LoadAsync(fx, reader);
var analysis = await new AnalysisPageLoader(reader, costs, periods)
.LoadAsync(analysisQuery, AnalysisSelection.Resolve(analysisQuery, options), options, Now);
Assert.Equal(156, analysis.Costs.Single().Current.Total.StandingCharge!.Value, 6);
var overview = await Dashboard().GetOverviewAsync(Custom(D(2024, 1, 1), D(2024, 12, 31)), BucketSize.Auto, PreviousYear);
Assert.Equal(156, overview.Types.Single(t => t.Type.Id == type).Cost!.Total.StandingCharge!.Value, 6);
}
private static Microsoft.Extensions.Options.IOptions<MeterVaultOptions> Options() =>
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
private (AnalysisReader Reader, CostReader Costs, AnalysisPeriods Periods) Readers()
{
var reader = new AnalysisReader(fx, Options());
var costs = new CostReader(fx, reader, Options());
return (reader, costs, new AnalysisPeriods(reader, costs));
}
private DashboardService Dashboard()
{
var clock = new FixedTimeProvider(Now);
return new DashboardService(fx, new CostService(fx, Options(), clock), clock);
}
}
@@ -0,0 +1,334 @@
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.App.Energy;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Dashboard;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The energy type page and the meter lists without a database (brief §7.3): the tab and view keys, which metrics a type
/// charts, how a meter counts in words (a breakdown names its parent, a calculated view its sources), meter rows that say
/// "No data" instead of a made-up zero, the largest changes over matched coverage, the flow table's words, and the
/// connection rules — no meter into itself, no duplicate, no loop, no silent change to a calculation.
/// </summary>
public sealed class EnergyPageTests
{
// ------------------------------------------------------------------------------------------------ keys
[Theory]
[InlineData("http://x/energy/3", "overview", "total")]
[InlineData("http://x/energy/3?tab=history", "history", "total")]
[InlineData("http://x/energy/3?tab=HISTORY&view=Meters", "history", "meters")]
[InlineData("http://x/energy/3?tab=flow&period=ytd", "flow", "total")]
[InlineData("http://x/energy/3?tab=meters&view=bogus", "meters", "total")]
[InlineData("http://x/energy/3?tab=sankey", "overview", "total")]
[InlineData("/energy/3?view=meters#top", "overview", "meters")]
public void Tab_and_view_keys_resolve_with_fallbacks(string uri, string tab, string view)
{
var (resolvedTab, resolvedView) = EnergyPageKeys.Parse(uri);
Assert.Equal(tab, resolvedTab);
Assert.Equal(view, resolvedView);
Assert.Equal(AnalysisLinks.EnergyTabs.ToList().IndexOf(tab), AnalysisLinks.EnergyTabIndex(resolvedTab));
}
[Fact]
public void The_tab_and_view_are_not_part_of_the_analysis_state()
{
// A tab or view switch must not reload the analysis (D-46): the parsed query is the same with or without them.
var defaults = AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(3));
var plain = AnalysisQuery.Parse("/energy/3?period=ytd", defaults);
var tabbed = AnalysisQuery.Parse("/energy/3?tab=history&view=meters&period=ytd", defaults);
Assert.Equal(plain, tabbed);
Assert.Equal("/energy/3?tab=history&period=ytd", AnalysisLinks.EnergyType(3, AnalysisLinks.EnergyTabHistory, plain));
}
// ------------------------------------------------------------------------------------------------ metrics
[Fact]
public void A_type_charts_the_metrics_of_its_measures_and_meters_and_its_cost()
{
var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31));
var use = Measure(TotalsMeasure.Use, QuantityKind.Consumption, buckets);
var generation = Measure(TotalsMeasure.Generation, QuantityKind.Generation, buckets);
var net = Series(9, "Balance", [Available(1), Available(-2), Available(3)], kind: QuantityKind.Net);
var result = Result(buckets, [net], [generation, use]);
var metrics = EnergyMetrics.Available(result, cost: null);
Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Net], metrics);
Assert.Equal(AnalysisMetric.Consumption, EnergyMetrics.Effective(null, metrics));
Assert.Equal(AnalysisMetric.Generation, EnergyMetrics.Effective(AnalysisMetric.Generation, metrics));
Assert.Equal(AnalysisMetric.Consumption, EnergyMetrics.Effective(AnalysisMetric.Balance, metrics));
Assert.Equal([use], EnergyMetrics.MeasuresOf(result, AnalysisMetric.Consumption));
Assert.Equal([net], EnergyMetrics.MetersOf(result, AnalysisMetric.Net));
Assert.Empty(EnergyMetrics.MeasuresOf(result, AnalysisMetric.Net));
}
// ------------------------------------------------------------------------------------------------ memberships
[Fact]
public void A_breakdown_names_its_parent_and_a_calculated_view_its_sources() => In("en", () =>
{
var names = new Dictionary<int, string> { [1] = "Haus", [4] = "Solar 1", [5] = "Solar 2" };
string Name(int id) => names[id];
var breakdown = MeterMembership.Of(Entry(3, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1]), null, Name)!;
Assert.Equal("Breakdown of a counted meter", breakdown.Label);
Assert.Equal("Part of Haus: shown, but never added on top.", breakdown.Detail);
Assert.False(breakdown.IsCounted);
var sum = Series(9, "Summe Solar", [Available(1)], kind: QuantityKind.Generation) with
{
Virtual = new VirtualSeriesInfo(VirtualMeterStatus.Valid, "m4 + m5", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None, [4, 5], [4, 5], [], null, null),
};
var view = MeterMembership.Of(Entry(9, MeterTotalsClass.AnalysisOnly, MeterTotalsReason.VirtualView), sum, Name)!;
Assert.Equal("Analysis only", view.Label);
Assert.Contains("Solar 1, Solar 2", view.Detail, StringComparison.Ordinal);
var grid = MeterMembership.Of(Entry(2, MeterTotalsClass.GridImport, MeterTotalsReason.GridImportRole, measure: TotalsMeasure.GridImport), null, Name)!;
Assert.True(grid.IsCounted);
Assert.Contains("never added", grid.Detail, StringComparison.Ordinal);
Assert.Null(MeterMembership.Of(null, null, Name));
});
[Fact]
public void Membership_wording_is_german_in_german() => In("de", () =>
{
var membership = MeterMembership.Of(Entry(3, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1]), null, _ => "Zähler Haus")!;
Assert.Equal("Aufschlüsselung eines gezählten Zählers", membership.Label);
Assert.Equal("Teil von Zähler Haus: wird gezeigt, aber nie zusätzlich addiert.", membership.Detail);
});
// ------------------------------------------------------------------------------------------------ meter rows
[Fact]
public void A_meter_without_data_reads_no_data_and_a_measured_zero_reads_zero() => In("en", () =>
{
var buckets = Buckets(D(2025, 1, 1), D(2025, 2, 28));
var silent = Series(1, "Silent", [Missing(), Missing()], BucketValue.Missing());
var idle = Series(2, "Idle", [Available(0), Available(0)], Available(0));
var result = Result(buckets, [silent, idle], []) with
{
Classification =
[
new MeterClassification(1, "Silent", Entry(1, MeterTotalsClass.Use, MeterTotalsReason.ConsumptionRoot, measure: TotalsMeasure.Use)),
new MeterClassification(2, "Idle", Entry(2, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1])),
],
};
var rows = MeterListRows.Build([Facts(2, "Idle"), Facts(1, "Silent"), Facts(3, "Added later")], result);
Assert.Equal(["Added later", "Idle", "Silent"], rows.Select(r => r.Meter.Name));
var silentRow = rows.Single(r => r.Meter.Id == 1);
Assert.Equal("No data", silentRow.ValueText);
Assert.False(silentRow.HasValue);
Assert.Equal("No data covers this period", silentRow.QualityDetail);
var idleRow = rows.Single(r => r.Meter.Id == 2);
Assert.Equal("0 kWh", idleRow.ValueText);
Assert.True(idleRow.HasValue);
Assert.Equal("Part of Silent: shown, but never added on top.", idleRow.Membership!.Detail);
// A meter the read did not cover has no figure at all, not a zero.
var later = rows.Single(r => r.Meter.Id == 3);
Assert.Equal(Format.Unknown, later.ValueText);
Assert.Null(later.Membership);
});
[Fact]
public void Meter_rows_search_names_serials_types_modes_and_membership() => In("en", () =>
{
var row = new MeterListRow(Facts(1, "Zähler Haus") with { SerialNumber = "SN-77", Location = "Keller" }, null,
new MeterMembership(MeterTotalsClass.Breakdown, "Breakdown of a counted meter", string.Empty), null);
Assert.True(MeterListRows.Matches(row, null));
Assert.True(MeterListRows.Matches(row, " haus "));
Assert.True(MeterListRows.Matches(row, "sn-7"));
Assert.True(MeterListRows.Matches(row, "keller"));
Assert.True(MeterListRows.Matches(row, "strom"));
Assert.True(MeterListRows.Matches(row, "cumulative"));
Assert.True(MeterListRows.Matches(row, "breakdown"));
Assert.False(MeterListRows.Matches(row, "wasser"));
});
[Fact]
public void Quick_entry_is_a_reading_a_tank_level_or_nothing_for_a_calculation()
{
Assert.Equal(MeterLinks.QuickEntry(1, MeterMode.CumulativeCounter), MeterListRows.QuickEntry(Facts(1, "A"))!.Value.Href);
Assert.True(MeterListRows.QuickEntry(Facts(2, "Tank") with { Mode = MeterMode.ConsumableBalance })!.Value.IsTank);
Assert.Null(MeterListRows.QuickEntry(Facts(3, "Sum") with { Mode = MeterMode.Virtual }));
}
// ------------------------------------------------------------------------------------------------ changes
[Fact]
public void Largest_changes_use_matched_values_and_skip_what_is_not_comparable()
{
var matched = new MatchedCoverageResult(
new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)),
new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)),
[new MatchedPiece(new MatchedRange(Now, Now, D(2025, 1, 1), D(2025, 1, 31)), new MatchedRange(Now, Now, D(2024, 1, 1), D(2024, 1, 31)))]);
AnalysisSeries With(int id, double current, double previous) =>
Series(id, $"M{id}", [Available(current)]) with
{
Comparison = new SeriesComparison([Available(previous)], Available(previous), matched, current, previous, Change.Between(current, previous)),
};
var notComparable = Series(9, "Not comparable", [Available(5)]) with
{
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
var changes = MeterChanges.Largest([With(1, 100, 90), With(2, 50, 150), With(3, 10, 10), notComparable], 2);
Assert.Equal([2, 1], changes.Select(c => c.Series.MeterId!.Value));
Assert.Equal(-100, changes[0].Change.Absolute!.Value, 6);
Assert.Equal(50, changes[0].Current, 6);
Assert.Equal(150, changes[0].Previous, 6);
}
// ------------------------------------------------------------------------------------------------ flow words
[Fact]
public void The_flow_table_words_every_ribbon_and_every_meter_it_does_not_draw() => In("en", () =>
{
var graph = new FlowGraph(1, "Strom", "kWh", 100, [
new FlowNode("m1", "Haus", 100, 0, null, false, 1),
new FlowNode("m2", "Auto", 30, 1, null, false, 2),
new FlowNode("other1", "Haus", 70, 1, null, true, null),
], [
new FlowLink("m1", "m2", 30),
new FlowLink("m1", "other1", 70),
new FlowLink("m4", "m9", 10) { IsCalculated = true },
new FlowLink("m3", "m1", 5) { IsEstimated = true },
new FlowLink("m3", "m2", 5) { IsEstimated = true, IsCapped = true },
]);
Assert.Equal("Other (Haus)", FlowText.NodeName(graph, "other1"));
Assert.Equal("Measured part", FlowText.EdgeKind(graph, graph.Links[0]));
Assert.Equal("Not measured by a meter below it", FlowText.EdgeKind(graph, graph.Links[1]));
Assert.Equal("Input of a calculated sum", FlowText.EdgeKind(graph, graph.Links[2]));
Assert.StartsWith("Estimated share", FlowText.EdgeKind(graph, graph.Links[3]), StringComparison.Ordinal);
Assert.StartsWith("Estimated, capped", FlowText.EdgeKind(graph, graph.Links[4]), StringComparison.Ordinal);
var water = new FlowMeter(7, "Water", 7, BucketStatus.Available, ValueIssue.None, QuantityKind.Consumption, "m³", SeriesBasis.Physical, false);
var difference = new FlowMeter(8, "A B", -50, BucketStatus.Available, ValueIssue.None, QuantityKind.Net, "kWh", SeriesBasis.Virtual, false);
var silent = new FlowMeter(9, "Silent", null, BucketStatus.Missing, ValueIssue.NoCoverage, QuantityKind.Consumption, "kWh", SeriesBasis.Physical, false);
Assert.Equal("Not drawn: measured in m³", FlowText.NotDrawnReason(graph, water));
Assert.Equal("Not drawn: a calculation that is not a plain sum", FlowText.NotDrawnReason(graph, difference));
Assert.Null(FlowText.NotDrawnReason(graph, water with { InDiagram = true }));
// Signed stays signed, and a meter without a number says so instead of "0".
Assert.Equal("-50 kWh", FlowText.MeterValue(difference));
Assert.Equal("No data", FlowText.MeterValue(silent));
});
// ------------------------------------------------------------------------------------------------ connection rules
[Fact]
public void A_connection_is_refused_into_itself_across_types_twice_or_around_a_loop()
{
var a = Link(1, "A");
var b = Link(2, "B");
var c = Link(3, "C");
var foreign = Link(4, "Foreign") with { EnergyTypeId = 2 };
(int, int)[] links = [(1, 2), (2, 3)];
Assert.Equal(MeterLinkRefusal.SameMeter, MeterLinkRules.CheckAdd(a, a, links, 1, 1).Refusal);
Assert.Equal(MeterLinkRefusal.UnknownMeter, MeterLinkRules.CheckAdd(a, null, links, 1, 99).Refusal);
Assert.Equal(MeterLinkRefusal.OtherEnergyType, MeterLinkRules.CheckAdd(a, foreign, links, 1, 4).Refusal);
Assert.Equal(MeterLinkRefusal.AlreadyLinked, MeterLinkRules.CheckAdd(a, b, links, 1, 2).Refusal);
// C → A would close A → B → C → A: refused, with the existing path from A to C.
var loop = MeterLinkRules.CheckAdd(c, a, links, 3, 1);
Assert.Equal(MeterLinkRefusal.WouldCreateCycle, loop.Refusal);
Assert.Equal([1, 2, 3], loop.Path);
Assert.Equal(MeterLinkRefusal.WouldCreateCycle, MeterLinkRules.CheckAdd(b, a, links, 2, 1).Refusal);
// A shortcut in the same direction is no loop.
Assert.True(MeterLinkRules.CheckAdd(a, c, links, 1, 3).IsAllowed);
}
[Fact]
public void A_virtual_meter_calculated_from_its_links_keeps_them()
{
var source = Link(1, "Solar 1");
var legacy = Link(9, "Legacy sum") with { Mode = MeterMode.Virtual, CalculatedFromLinks = true };
var defined = Link(10, "Summe Solar") with { Mode = MeterMode.Virtual, ReferencedMeterIds = [1] };
Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, MeterLinkRules.CheckAdd(source, legacy, [], 1, 9).Refusal);
Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, MeterLinkRules.CheckRemove(legacy).Refusal);
// A stored calculation is the formula's, never the links': those stay free to edit.
Assert.True(MeterLinkRules.CheckAdd(source, defined, [], 1, 10).IsAllowed);
Assert.True(MeterLinkRules.CheckRemove(defined).IsAllowed);
var topology = new MeterLinkTopology(1, [source, defined], [new MeterLinkEntry(5, 1, 10)], new Dictionary<int, MeterLinkMeter>(), [(1, 10)]);
Assert.True(topology.MirrorsCalculation(topology.Links[0]));
}
[Fact]
public void A_refusal_is_worded_with_the_meters_named() => In("en", () =>
{
var names = new Dictionary<int, string> { [1] = "Haus", [2] = "Auto", [3] = "Wallbox" };
var loop = new MeterLinkCheck(MeterLinkRefusal.WouldCreateCycle, [1, 2, 3]);
Assert.Equal("This would make a loop: Haus → Auto → Wallbox → Haus.", FlowText.Refusal(loop, id => names[id], 1));
Assert.Contains("Wallbox is calculated from its connections", FlowText.Refusal(MeterLinkCheck.Refused(MeterLinkRefusal.CalculatedFromLinks), id => names[id], 3), StringComparison.Ordinal);
Assert.Equal(string.Empty, FlowText.Refusal(MeterLinkCheck.Allowed, id => names[id], 1));
});
[Fact]
public void Describing_a_meter_reads_whether_its_calculation_is_stored()
{
var legacy = new Meter { Id = 9, Name = "Legacy", EnergyTypeId = 1, Mode = MeterMode.Virtual, Unit = "kWh", Meta = "{}" };
var defined = new Meter
{
Id = 10,
Name = "Sum",
EnergyTypeId = 1,
Mode = MeterMode.Virtual,
Unit = "kWh",
Meta = MeterVault.Core.Analysis.Virtual.VirtualDefinitionJson.Write(
"{}", new MeterVault.Core.Analysis.Virtual.VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None)),
};
var physical = new Meter { Id = 4, Name = "Solar 1", EnergyTypeId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh", Meta = "{}" };
Assert.True(MeterLinkRules.Describe(legacy).CalculatedFromLinks);
Assert.False(MeterLinkRules.Describe(defined).CalculatedFromLinks);
Assert.Equal([4, 5], MeterLinkRules.Describe(defined).ReferencedMeterIds);
Assert.False(MeterLinkRules.Describe(physical).CalculatedFromLinks);
}
// ------------------------------------------------------------------------------------------------ helpers
private static AnalysisSeries Measure(TotalsMeasure measure, QuantityKind kind, IReadOnlyList<AnalysisBucket> buckets) =>
new(SeriesKey.ForMeasure(1, measure, "kWh"), string.Empty, SeriesBasis.Measure, kind, "kWh",
[.. buckets.Select(_ => Available(1))], Available(buckets.Count), IsAdditive: true);
private static AnalysisResult Result(IReadOnlyList<AnalysisBucket> buckets, IReadOnlyList<AnalysisSeries> series, IReadOnlyList<AnalysisSeries> measures)
{
var period = Range(buckets[0].FirstDay, buckets[^1].EndDay.AddDays(-1));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
return new AnalysisResult(new AnalysisRequest(AnalysisScope.ForEnergyType(1), period), plan, series, measures, ScopeAvailability.None, []);
}
private static MeterTotalsEntry Entry(
int meterId, MeterTotalsClass @class, MeterTotalsReason reason, TotalsMeasure? measure = null, IReadOnlyList<int>? parents = null) =>
new(meterId, 1, @class, reason, @class, measure, parents ?? [], [], null, null);
private static MeterFacts Facts(int id, string name) =>
new(id, name, 1, "Strom", MeterMode.CumulativeCounter, "kWh", IsActive: true, null, null, HasTank: false);
private static MeterLinkMeter Link(int id, string name) => new(id, name, 1, MeterMode.CumulativeCounter, true, false, []);
}
@@ -0,0 +1,110 @@
using MeterVault.App.Analysis;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// Only the latest requested load is committed (brief §8, finding A13): a delayed first request cannot overwrite the
/// scope or range the user chose after it, and a failure keeps the previous result visible. Pure; no database.
/// </summary>
public sealed class LoadSequencerTests
{
[Fact]
public void A_new_ticket_cancels_the_previous_one()
{
using var loads = new LoadSequencer();
var first = loads.Next();
Assert.True(loads.IsCurrent(first));
var second = loads.Next();
Assert.True(first.Token.IsCancellationRequested);
Assert.False(loads.IsCurrent(first));
Assert.True(loads.IsCurrent(second));
Assert.Equal(2, second.Generation);
Assert.Equal(2, loads.Generation);
}
[Fact]
public void Disposing_cancels_the_load_in_flight()
{
var loads = new LoadSequencer();
var ticket = loads.Next();
loads.Dispose();
loads.Dispose();
Assert.True(ticket.Token.IsCancellationRequested);
Assert.False(loads.IsCurrent(ticket));
Assert.Throws<ObjectDisposedException>(() => loads.Next());
}
[Fact]
public async Task A_delayed_first_load_cannot_overwrite_the_later_one()
{
using var loads = new LoadSequencer();
var state = new LoadState<string>();
var slow = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var fast = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// Type 1 is requested, then type 2 before type 1's answer arrives; the answers arrive in the other order.
var first = loads.RunAsync(state, _ => slow.Task);
var second = loads.RunAsync(state, _ => fast.Task);
Assert.True(state.IsInitialLoad);
fast.SetResult("type 2");
Assert.True(await second);
Assert.Equal("type 2", state.Value);
Assert.False(state.IsLoading);
slow.SetResult("type 1");
Assert.False(await first);
Assert.Equal("type 2", state.Value);
}
[Fact]
public async Task A_superseded_load_that_honours_its_token_just_stops()
{
using var loads = new LoadSequencer();
var state = new LoadState<string>();
var first = loads.RunAsync(state, async token =>
{
await Task.Delay(Timeout.Infinite, token);
return "never";
});
var second = loads.RunAsync(state, _ => Task.FromResult("latest"));
Assert.True(await second);
Assert.False(await first);
Assert.Equal("latest", state.Value);
Assert.Null(state.Error);
}
[Fact]
public async Task A_failure_keeps_the_previous_value_visible_and_is_reported()
{
using var loads = new LoadSequencer();
var state = new LoadState<string>();
await loads.RunAsync(state, _ => Task.FromResult("before"));
var refresh = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var running = loads.RunAsync(state, _ => refresh.Task);
Assert.True(state.IsRefreshing);
Assert.True(state.IsStale);
Assert.False(state.IsInitialLoad);
refresh.SetException(new InvalidOperationException("database gone"));
Assert.True(await running);
Assert.Equal("before", state.Value);
Assert.IsType<InvalidOperationException>(state.Error);
Assert.True(state.IsStale);
Assert.False(state.IsLoading);
// Retry succeeds: fresh again.
await loads.RunAsync(state, _ => Task.FromResult("after"));
Assert.Equal("after", state.Value);
Assert.Null(state.Error);
Assert.False(state.IsStale);
}
}
@@ -0,0 +1,129 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Integration.Tests.Costing;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// Saving a role (review virtual F1, D-21, A-07): it moves from the holder in service instead of leaving two, a retired
/// holder keeps it for its history, and a meter whose mode may not hold a role does not keep one.
/// </summary>
[Collection("Timescale")]
public sealed class MeterRoleAssignmentTests(TimescaleFixture fx) : IAsyncLifetime
{
private CostSandbox _box = null!;
public Task InitializeAsync()
{
_box = new CostSandbox(fx);
return Task.CompletedTask;
}
public async Task DisposeAsync() => await _box.DisposeAsync();
[Fact]
public async Task Saving_grid_import_on_a_new_meter_moves_it_from_the_old_one_and_the_bill_follows()
{
// The grid meter was replaced by a new record, and the old one never retired. Before, the lower id kept the role
// and the new meter dropped out of every measure and the bill.
var type = await _box.TypeAsync();
var old = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await _box.MonthlyReadingsAsync(old, D(2026, 1, 1), 100);
var replacement = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await _box.MonthlyReadingsAsync(replacement, D(2026, 2, 1), 80);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
await using (var db = fx.CreateContext())
{
var holder = MeterRoleAssignment.CurrentHolder(
await db.Meters.Where(m => m.EnergyTypeId == type).ToListAsync(), MeterRole.GridImport, type, replacement, takerRetired: false);
Assert.Equal(old, holder!.Id);
await using var tx = await db.Database.BeginTransactionAsync();
var moved = await MeterRoleAssignment.ApplyAsync(db, replacement, Normalization(db));
await tx.CommitAsync();
Assert.Equal([old], moved.Select(m => m.Id));
}
await using (var db = fx.CreateContext())
{
Assert.Null(MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == old)).Meta));
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == replacement)).Meta));
}
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 2)) { Bucket = BucketSize.Month });
Assert.Equal(BillingBasis.GridImport, Assert.Single(bill.EnergyTypes).Basis);
Assert.Contains(bill.Lines, l => l.MeterId == replacement);
CostAssert.Priced(8, bill.Total);
}
[Fact]
public async Task A_retired_holder_keeps_its_role_and_so_does_its_successor()
{
var type = await _box.TypeAsync();
var retired = await _box.MeterAsync(
type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport), retiredAt: D(2026, 1, 31));
var successor = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await using var db = fx.CreateContext();
Assert.Null(MeterRoleAssignment.CurrentHolder(await db.Meters.Where(m => m.EnergyTypeId == type).ToListAsync(), MeterRole.GridImport, type, successor, false));
Assert.Empty(await MeterRoleAssignment.ApplyAsync(db, successor, Normalization(db)));
Assert.Empty(await MeterRoleAssignment.ApplyAsync(db, retired, Normalization(db)));
db.ChangeTracker.Clear();
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == retired)).Meta));
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == successor)).Meta));
}
[Theory]
[InlineData(MeterMode.Virtual)]
[InlineData(MeterMode.GenerationCounter)]
[InlineData(MeterMode.ConsumableBalance)]
[InlineData(MeterMode.RuntimeCounter)]
public async Task A_meter_whose_mode_may_not_hold_a_role_does_not_keep_one(MeterMode mode)
{
var type = await _box.TypeAsync();
var holder = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
var meter = await _box.MeterAsync(type, mode, "kWh", D(2026, 1, 1), MeterMeta.WithRole("""{"note":"kept"}""", MeterRoles.TotalLoad));
Assert.Null(MeterRoleAssignment.AllowedToken(mode, MeterRoles.TotalLoad));
await using var db = fx.CreateContext();
Assert.Empty(await MeterRoleAssignment.ApplyAsync(db, meter, Normalization(db)));
db.ChangeTracker.Clear();
var stored = await db.Meters.SingleAsync(m => m.Id == meter);
Assert.Null(MeterMeta.Role(stored.Meta));
Assert.Equal("kept", MeterMeta.ReadString(stored.Meta, "note"));
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == holder)).Meta));
}
[Fact]
public async Task A_displaced_export_meter_is_rebuilt_as_consumption()
{
// The rollup state records the kind a role gives a meter (D-20): an export meter that gives grid_export up measures
// consumption from then on, so it is recomputed with the move.
var type = await _box.TypeAsync();
var export = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport));
await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50);
var taker = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport));
await using var db = fx.CreateContext();
Assert.Equal(QuantityKind.Export, (await db.MeterRollupStates.SingleAsync(s => s.MeterId == export)).Kind);
await using (var tx = await db.Database.BeginTransactionAsync())
{
await MeterRoleAssignment.ApplyAsync(db, taker, Normalization(db));
await tx.CommitAsync();
}
db.ChangeTracker.Clear();
Assert.Equal(QuantityKind.Consumption, (await db.MeterRollupStates.SingleAsync(s => s.MeterId == export)).Kind);
}
}
@@ -0,0 +1,98 @@
using MeterVault.App.Analysis;
using MeterVault.App.Localization;
using MeterVault.App.TariffEditing;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The seams where the reworked pages meet (integration of the page reworks): a cost whose quantity is unknown says so
/// instead of "Priced" beside "—"; the meter's Calculation tab words a finding like the attention list, leaving the
/// meters to its links; the tariff list and a meter's Tariffs tab agree on when a tariff ends. Pure; no database.
/// </summary>
public sealed class PageIntegrationTests
{
[Fact]
public void A_priced_bucket_without_quantity_reads_as_its_quantity_status() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var tariff = new Tariff
{
Id = 1,
ScopeType = TariffScope.EnergyType,
ScopeId = 1,
Component = TariffComponent.UnitPrice,
Value = 0.3,
Unit = "EUR/kWh",
ValidFrom = D(2000, 1, 1),
};
var parts = CostCalculator.Parts(buckets).ToList();
List<CostQuantity> quantities = [CostQuantity.Known(parts[0], 100), CostQuantity.Unknown(parts[1])];
var line = new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities);
var cost = CostCalculator.Calculate(new CostRequest(buckets, D(2039, 12, 31), TariffBook.Create([tariff], "EUR"), [line]));
var priced = FigureText.Of(cost.Totals[0]);
Assert.True(priced.IsKnown);
Assert.Equal("Priced", priced.Status);
var noQuantity = FigureText.Of(cost.Totals[1]);
Assert.False(noQuantity.IsKnown);
Assert.Equal(BucketStatus.Missing.Display(), noQuantity.Status);
Assert.Null(noQuantity.Detail);
// A known cost over incomplete quantities keeps naming its prices, with the quantities' state as detail.
var total = FigureText.Of(cost.Total);
Assert.True(total.IsKnown);
Assert.Equal("Priced", total.Status);
Assert.Contains(BucketStatus.Partial.Display(), total.Detail);
});
[Fact]
public void The_calculation_tab_words_a_finding_like_the_attention_list_without_the_meters() => In("en", () =>
{
var names = new AttentionNames(new Dictionary<int, string> { [4] = "Solar 1", [8] = "Wasser" });
var units = new VirtualProblem(VirtualProblemKind.UnitMismatch, [4, 8], ["kWh", "m³"]);
var cycle = new VirtualProblem(VirtualProblemKind.DependencyCycle, [9, 4, 9], []);
var kinds = new VirtualProblem(VirtualProblemKind.ResultKindRequired, [4, 8], ["generation", "mixed"]);
Assert.Equal(VirtualProblemKind.UnitMismatch.Display() + " (kWh, m³)", AttentionItems.VirtualReasonWithoutMeters(units));
Assert.Equal(VirtualProblemKind.DependencyCycle.Display(), AttentionItems.VirtualReasonWithoutMeters(cycle));
Assert.StartsWith(VirtualProblemKind.ResultKindRequired.Display() + " (" + QuantityKind.Generation.Display() + ", ",
AttentionItems.VirtualReasonWithoutMeters(kinds));
Assert.EndsWith(", gemischt)", In("de", () => AttentionItems.VirtualReasonWithoutMeters(kinds)));
// The attention list names the meters itself.
Assert.Contains("Solar 1", AttentionItems.VirtualReason(new VirtualProblem(VirtualProblemKind.SourceInvalid, [4], []), names));
Assert.Equal(
VirtualProblemKind.DependencyCycle.Display() + " (Meter #9 → Solar 1 → Meter #9)",
AttentionItems.VirtualReason(cycle, names));
});
[Fact]
public void A_tariff_ends_where_the_next_one_of_its_kind_starts()
{
TariffSpan[] tariffs =
[
new(1, TariffScope.EnergyType, 1, TariffComponent.UnitPrice, D(2023, 1, 1), null),
new(2, TariffScope.EnergyType, 1, TariffComponent.UnitPrice, D(2024, 1, 1), null),
new(3, TariffScope.EnergyType, 1, TariffComponent.UnitPrice, D(2025, 1, 1), D(2025, 6, 30)),
new(4, TariffScope.EnergyType, 2, TariffComponent.UnitPrice, D(2023, 6, 1), null),
new(5, TariffScope.EnergyType, 1, TariffComponent.BasePrice, D(2023, 1, 1), D(2030, 12, 31)),
new(6, TariffScope.EnergyType, 1, TariffComponent.BasePrice, D(2026, 1, 1), null),
];
var ends = TariffValidity.EffectiveEnds(tariffs);
Assert.Equal(D(2023, 12, 31), ends[1]);
Assert.Equal(D(2024, 12, 31), ends[2]);
Assert.Equal(D(2025, 6, 30), ends[3]);
Assert.Null(ends[4]);
Assert.Equal(D(2025, 12, 31), ends[5]);
Assert.Null(ends[6]);
}
}
@@ -0,0 +1,73 @@
using MeterVault.App;
using MeterVault.App.Theme;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The cookie forms of the shell's preferences (D-48, D-49): the expanded navigation groups, the group holding a route,
/// and the theme. Pure; no database.
/// </summary>
public sealed class ShellPreferenceTests
{
[Fact]
public void Navigation_groups_round_trip_through_their_cookie()
{
Assert.Null(NavGroups.Parse(null));
Assert.Null(NavGroups.Parse(" "));
Assert.Null(NavGroups.Parse("nonsense.more"));
Assert.Equal(["config", "types"], NavGroups.Parse("types.config")!.Order());
Assert.Equal(["views"], NavGroups.Parse("VIEWS.unknown")!);
Assert.Empty(NavGroups.Parse("none")!);
Assert.Equal("types.config", NavGroups.Format(["config", "types"]));
Assert.Equal("none", NavGroups.Format([]));
Assert.Equal("types.views", NavGroups.Format(NavGroups.DefaultExpanded));
}
[Theory]
[InlineData("energy/3", "types")]
[InlineData("/energy/3?tab=history", "types")]
[InlineData("solar", "views")]
[InlineData("consumables?period=ytd", "views")]
[InlineData("admin/tariffs", "config")]
[InlineData("http://localhost:8760/admin/settings", "config")]
[InlineData("", null)]
[InlineData("meters/5", null)]
[InlineData("trends", null)]
[InlineData("import", null)]
public void The_group_holding_a_route_is_known(string path, string? group) =>
Assert.Equal(group, NavGroups.GroupFor(path));
[Fact]
public void Nav_state_starts_from_the_request_cookie()
{
var state = new NavState();
Assert.Null(state.SavedGroups);
state.InitializeGroups("config");
Assert.Equal(["config"], state.SavedGroups!);
var raised = 0;
state.MetersChanged += () => raised++;
state.NotifyMetersChanged();
Assert.Equal(1, raised);
}
[Theory]
[InlineData("dark", true)]
[InlineData("LIGHT", false)]
[InlineData(" light ", false)]
[InlineData("sepia", null)]
[InlineData(null, null)]
public void The_theme_cookie_names_a_mode(string? cookie, bool? dark) =>
Assert.Equal(dark, ThemeState.Parse(cookie));
[Fact]
public void The_theme_defaults_to_dark_and_tokens_round_trip()
{
Assert.True(ThemeState.DefaultIsDark);
Assert.True(ThemeState.Parse(ThemeState.Token(true)));
Assert.False(ThemeState.Parse(ThemeState.Token(false)));
}
}
@@ -0,0 +1,229 @@
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// Managing virtual meters outside an analysis read: the startup conversion of expression-less ("legacy") virtual
/// meters to explicit definitions (D-28), and the dependents a meter's delete dialog must name (D-33). Every test
/// creates its own energy type and meters and removes them again; assertions only look at those meters, because
/// the upgrade runs over the whole instance.
/// </summary>
[Collection("Timescale")]
public sealed class VirtualManagementTests(TimescaleFixture fx) : IAsyncLifetime
{
private const string Zone = "Europe/Berlin";
private readonly List<int> _meters = [];
private readonly List<short> _types = [];
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
var ids = _meters.ToArray();
await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync();
await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync();
await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync();
var types = _types.ToArray();
await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync();
}
[Fact]
public async Task Legacy_meters_get_the_sum_their_links_imply_in_dependency_order()
{
var type = await TypeAsync();
var solar1 = await MeterAsync(type, MeterMode.GenerationCounter);
var solar2 = await MeterAsync(type, MeterMode.GenerationCounter);
var solar3 = await MeterAsync(type, MeterMode.GenerationCounter);
var house = await MeterAsync(type, MeterMode.CumulativeCounter);
// B sums A (itself legacy) and Solar 3, so A has to be written first. A keeps a key of its own.
var b = await MeterAsync(type, MeterMode.Virtual);
var a = await MeterAsync(type, MeterMode.Virtual, meta: """{"note":"kept"}""");
await LinksAsync((solar1, a), (solar2, a), (a, b), (solar3, b));
// Not convertible: consumption plus generation, and nothing linked at all.
var mixed = await MeterAsync(type, MeterMode.Virtual);
await LinksAsync((house, mixed), (solar1, mixed));
var lonely = await MeterAsync(type, MeterMode.Virtual);
// Never touched: an explicit expression (the authority, whatever links say) and a malformed blob.
var definedMeta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{solar1}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts));
var defined = await MeterAsync(type, MeterMode.Virtual, meta: definedMeta);
var malformed = await MeterAsync(type, MeterMode.Virtual, meta: """{"expression":5}""");
await LinksAsync((solar2, defined), (solar1, malformed), (solar2, malformed));
var before = await MetasAsync();
var result = await UpgradeAsync();
// A before B, as the derivation saw them; nothing else of this test converted.
Assert.Equal([a, b], result.Converted.Where(_meters.Contains));
Assert.DoesNotContain(result.Failed, _meters.Contains);
var unresolved = result.NeedsConfiguration.Where(u => _meters.Contains(u.MeterId)).ToDictionary(u => u.MeterId, u => u.Outcome);
Assert.Equal(LegacyDerivationOutcome.MixedKinds, unresolved[mixed]);
Assert.Equal(LegacyDerivationOutcome.NoSources, unresolved[lonely]);
Assert.Equal(2, unresolved.Count);
var after = await MetasAsync();
var aRead = VirtualDefinitionJson.Read(after[a]);
Assert.Equal(VirtualDefinitionReadStatus.Present, aRead.Status);
Assert.Equal($"m{solar1} + m{solar2}", aRead.Definition!.Expression);
Assert.Equal(QuantityKind.Generation, aRead.Definition.ResultKind);
Assert.Equal("kWh", aRead.Definition.ResultUnit);
Assert.Equal(VirtualCostRule.None, aRead.Definition.CostRule); // a generation sum is not costed (A-15)
Assert.False(aRead.ReferencedIdsStale);
using (var doc = JsonDocument.Parse(after[a]))
{
Assert.Equal("kept", doc.RootElement.GetProperty("note").GetString());
}
var bRead = VirtualDefinitionJson.Read(after[b]);
Assert.Equal(new[] { a, solar3 }.Order(), bRead.Definition!.ReferencedMeterIds);
Assert.True(bRead.Definition.Formula!.IsPureSum);
Assert.Equal(QuantityKind.Generation, bRead.Definition.ResultKind);
// What cannot be converted, or needs no conversion, is left exactly as it was.
foreach (var id in new[] { mixed, lonely, defined, malformed })
{
Assert.Equal(before[id], after[id]);
}
// The converted meters record the kind their definition now declares.
await using (var db = fx.CreateContext())
{
var state = await db.MeterRollupStates.AsNoTracking().SingleAsync(s => s.MeterId == a);
Assert.Equal(QuantityKind.Generation, state.Kind);
Assert.Equal("kWh", state.NormalizedUnit);
}
// The reader now evaluates the stored definitions; the links are topology only.
var catalog = await Reader().LoadCatalogAsync();
Assert.Equal(VirtualMeterStatus.Valid, catalog.Find(a)!.VirtualStatus);
Assert.Equal(VirtualMeterStatus.Valid, catalog.Find(b)!.VirtualStatus);
Assert.Equal(VirtualMeterStatus.NeedsConfiguration, catalog.Find(mixed)!.VirtualStatus);
Assert.Equal(VirtualMeterStatus.Malformed, catalog.Find(malformed)!.VirtualStatus);
// Idempotent: a second run converts nothing and changes nothing.
var rerun = await UpgradeAsync();
Assert.DoesNotContain(rerun.Converted, _meters.Contains);
Assert.Equal(after, await MetasAsync());
}
[Fact]
public async Task A_converted_calculation_no_longer_follows_its_links()
{
var type = await TypeAsync();
var solar1 = await MeterAsync(type, MeterMode.GenerationCounter);
var solar2 = await MeterAsync(type, MeterMode.GenerationCounter);
var sum = await MeterAsync(type, MeterMode.Virtual);
await LinksAsync((solar1, sum), (solar2, sum));
await UpgradeAsync();
// Brief §5.2: after the conversion, editing the flow links must not secretly change the calculation.
await using (var db = fx.CreateContext())
{
await db.MeterLinks.Where(l => l.FromMeterId == solar2 && l.ToMeterId == sum).ExecuteDeleteAsync();
}
var catalog = await Reader().LoadCatalogAsync();
Assert.Equal(VirtualMeterStatus.Valid, catalog.Find(sum)!.VirtualStatus);
Assert.Equal(new[] { solar1, solar2 }.Order(), catalog.Find(sum)!.Formula!.MeterIds);
}
[Fact]
public async Task Dependents_name_every_virtual_meter_that_reads_a_meter()
{
var type = await TypeAsync();
var p = await MeterAsync(type, MeterMode.GenerationCounter, name: "P");
var q = await MeterAsync(type, MeterMode.GenerationCounter, name: "Q");
var direct = await VirtualAsync(type, "Direct", $"m{p} + m{q}", QuantityKind.Generation, VirtualCostRule.SourceCosts);
var nested = await VirtualAsync(type, "Nested", $"m{direct} + m{q}", QuantityKind.Generation, VirtualCostRule.SourceCosts);
var unrelated = await VirtualAsync(type, "Only Q", $"m{q}", QuantityKind.Generation, VirtualCostRule.SourceCosts);
// An invalid formula still breaks when p goes, and a legacy sum reads p through its link.
var invalid = await VirtualAsync(type, "Invalid", $"m{p} * m{q}", QuantityKind.Generation, VirtualCostRule.None);
var legacy = await MeterAsync(type, MeterMode.Virtual, name: "Legacy");
await LinksAsync((p, legacy));
var service = new VirtualMeterService(Reader());
var ofP = (await service.GetDependentsAsync(p)).ToDictionary(d => d.MeterId);
Assert.Equal(new[] { direct, nested, invalid, legacy }.Order(), ofP.Keys.Order());
Assert.DoesNotContain(unrelated, ofP.Keys);
Assert.True(ofP[direct].IsDirect);
Assert.Equal([direct, p], ofP[direct].Path);
Assert.False(ofP[nested].IsDirect);
Assert.Equal([nested, direct, p], ofP[nested].Path);
Assert.Equal(VirtualMeterStatus.Invalid, ofP[invalid].Status);
Assert.Equal(VirtualMeterStatus.Legacy, ofP[legacy].Status);
Assert.Equal("Direct", ofP[direct].Name);
var ofQ = (await service.GetDependentsAsync(q)).Select(d => d.MeterId).Order();
Assert.Equal(new[] { direct, nested, unrelated, invalid }.Order(), ofQ);
// Nothing reads the outermost sum.
Assert.Empty(await service.GetDependentsAsync(nested));
}
// ------------------------------------------------------------------------------------------------ helpers
private AnalysisReader Reader() =>
new(fx, Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = Zone }));
private async Task<VirtualDefinitionUpgradeResult> UpgradeAsync()
{
await using var db = fx.CreateContext();
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = Zone });
var normalization = new NormalizationService(db, NormalizationEngine.CreateDefault(), options);
return await new VirtualDefinitionUpgrade(db, normalization, NullLogger<VirtualDefinitionUpgrade>.Instance).RunAsync();
}
private async Task<Dictionary<int, string>> MetasAsync()
{
await using var db = fx.CreateContext();
var ids = _meters.ToArray();
return await db.Meters.AsNoTracking().Where(m => ids.Contains(m.Id)).ToDictionaryAsync(m => m.Id, m => m.Meta);
}
private async Task<short> TypeAsync()
{
await using var db = fx.CreateContext();
var type = new EnergyType { Key = $"virtual-{Guid.NewGuid():N}", DisplayName = "Virtual test", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_types.Add(type.Id);
return type.Id;
}
private async Task<int> MeterAsync(short type, MeterMode mode, string meta = "{}", string? name = null)
{
await using var db = fx.CreateContext();
var meter = new Meter { Name = name ?? $"virtual-{Guid.NewGuid():N}", EnergyTypeId = type, Mode = mode, Unit = "kWh", Meta = meta };
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
return meter.Id;
}
private Task<int> VirtualAsync(short type, string name, string expression, QuantityKind kind, VirtualCostRule rule) =>
MeterAsync(type, MeterMode.Virtual, VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, "kWh", rule)), name);
private async Task LinksAsync(params (int From, int To)[] links)
{
await using var db = fx.CreateContext();
db.MeterLinks.AddRange(links.Select(l => new MeterLink { FromMeterId = l.From, ToMeterId = l.To }));
await db.SaveChangesAsync();
}
}