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();
}
}
+478
View File
@@ -0,0 +1,478 @@
using System.Net;
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using MeterVault.Integration.Tests.Costing;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests;
/// <summary>
/// Pins the JSON shape of the read endpoints other systems consume (brief §9.9, note D-45). The analysis
/// rework reroutes what feeds them; these contracts are what must not move: every existing field keeps
/// its name and JSON type, and new information arrives only as additional fields — the quantity's status,
/// kind and unit, the cost's status and missing prices, the summary's percentage applicability and latest month.
/// </summary>
/// <remarks>
/// The summary covers the whole instance, so every test starts from — and leaves — an instance without meters,
/// tariffs or manual costs (like <see cref="DashboardRenderTests"/>). The app runs on the clock of
/// <see cref="CostSandbox.Now"/> (19 September 2026, 14:37 Berlin).
/// </remarks>
[Collection("Timescale")]
public sealed class ApiContractTests(TimescaleFixture fx) : IAsyncLifetime
{
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task Consumption_and_cost_keep_their_fields_and_types()
{
var meterId = await CreateMeterWithTwoMonthsAsync();
try
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
using var consumption = await GetJsonAsync(client, $"/api/v1/consumption?meter={meterId}&from=2024-01-01T00:00:00Z&to=2024-03-01T00:00:00Z");
var rows = consumption.RootElement.EnumerateArray().ToList();
Assert.Equal(2, rows.Count);
foreach (var row in rows)
{
AssertProperty(row, "period", JsonValueKind.String);
AssertProperty(row, "consumption", JsonValueKind.Number);
AssertProperty(row, "generation", JsonValueKind.Number);
// Added (D-45): whether the quantity can be trusted, and what it is in.
AssertString(row, "status", "Available");
AssertString(row, "issue", "None");
AssertString(row, "kind", "Consumption");
AssertString(row, "unit", "kWh");
}
Assert.Equal("2024-01-01", rows[0].GetProperty("period").GetString());
Assert.Equal(100, rows[0].GetProperty("consumption").GetDouble(), 6);
Assert.Equal(50, rows[1].GetProperty("consumption").GetDouble(), 6);
using var cost = await GetJsonAsync(client, $"/api/v1/cost?meter={meterId}&from=2024-01-01T00:00:00Z&to=2024-03-01T00:00:00Z");
var costRows = cost.RootElement.EnumerateArray().ToList();
Assert.Equal(2, costRows.Count);
foreach (var row in costRows)
{
AssertProperty(row, "period", JsonValueKind.String);
AssertProperty(row, "consumption", JsonValueKind.Number);
AssertProperty(row, "generation", JsonValueKind.Number);
AssertProperty(row, "cost", JsonValueKind.Number);
// Added (D-45): the price coverage of the cost.
AssertString(row, "costStatus", "Priced");
AssertProperty(row, "missingPrices", JsonValueKind.Array);
Assert.Empty(row.GetProperty("missingPrices").EnumerateArray());
AssertString(row, "status", "Available");
}
Assert.Equal(30, costRows[0].GetProperty("cost").GetDouble(), 6);
Assert.Equal(15, costRows[1].GetProperty("cost").GetDouble(), 6);
}
finally
{
await CleanupAsync(meterId);
}
}
[Fact]
public async Task Bounds_with_an_offset_are_accepted()
{
// A caller in Berlin sends its local midnight. Npgsql only accepts UTC instants for timestamptz,
// so the endpoint must convert rather than fail with a server error.
var meterId = await CreateMeterWithTwoMonthsAsync();
try
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var from = Uri.EscapeDataString("2024-01-01T00:00:00+01:00");
var to = Uri.EscapeDataString("2024-03-01T00:00:00+01:00");
using var response = await client.GetAsync(new Uri($"/api/v1/cost?meter={meterId}&from={from}&to={to}", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
finally
{
await CleanupAsync(meterId);
}
}
[Fact]
public async Task A_missing_price_leaves_cost_at_zero_and_says_why()
{
// D-38 on the API: cost stays a number, 0 when nothing could be priced, and the new fields say why — a gap in a
// priced history (January, before the meter's own price starts) or no tariff at all.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var gap = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
await box.MeterPriceAsync(gap, 0.30, D(2024, 2, 1));
var unpricedType = await box.TypeAsync();
var unpriced = await box.MonthlyAsync(unpricedType, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
using var api = new FrozenApi(fx.ConnectionString);
var client = api.Client;
var rows = (await GetJsonAsync(client, CostUrl(gap))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, rows.Count);
var january = rows[0];
Assert.Equal(0, january.GetProperty("cost").GetDouble());
Assert.Equal(100, january.GetProperty("consumption").GetDouble(), 6);
AssertString(january, "costStatus", "PriceGap");
var missing = Assert.Single(january.GetProperty("missingPrices").EnumerateArray().ToList());
AssertString(missing, "component", "UnitPrice");
AssertString(missing, "reason", "PriceGap");
AssertString(missing, "scope", "Meter");
Assert.Equal(gap, missing.GetProperty("scopeId").GetInt32());
Assert.Equal(gap, missing.GetProperty("meterId").GetInt32());
AssertString(missing, "firstMonth", "2024-01-01");
AssertString(missing, "lastMonth", "2024-01-01");
Assert.Equal(JsonValueKind.False, missing.GetProperty("isCredit").ValueKind);
var february = rows[1];
Assert.Equal(15, february.GetProperty("cost").GetDouble(), 6);
AssertString(february, "costStatus", "Priced");
var notPriced = (await GetJsonAsync(client, CostUrl(unpriced))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, notPriced.Count);
Assert.All(notPriced, row =>
{
Assert.Equal(0, row.GetProperty("cost").GetDouble());
AssertString(row, "costStatus", "NotPriced");
AssertString(Assert.Single(row.GetProperty("missingPrices").EnumerateArray().ToList()), "reason", "NotPriced");
});
}
[Fact]
public async Task A_month_with_only_a_cost_is_a_cost_row_but_no_consumption_row()
{
// A meter's own standing charge accrues through a reading gap (D-40): February costs 3 € although nothing was
// read. /cost reports it with the quantity's status; /consumption, which lists quantities, leaves it out.
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);
await box.MeterPriceAsync(meter, 0.30, D(2024, 1, 1));
await box.TariffAsync(TariffScope.Meter, meter, TariffComponent.BasePrice, 3, "EUR/month", D(2024, 1, 1));
using var api = new FrozenApi(fx.ConnectionString);
var costs = (await GetJsonAsync(api.Client, CostUrl(meter))).RootElement.EnumerateArray().ToList();
Assert.Equal([33d, 3d], costs.Select(r => Math.Round(r.GetProperty("cost").GetDouble(), 6)));
AssertString(costs[1], "status", "Missing");
AssertString(costs[1], "costStatus", "Priced");
var consumption = (await GetJsonAsync(api.Client, ConsumptionUrl(meter))).RootElement.EnumerateArray().ToList();
var january = Assert.Single(consumption);
AssertString(january, "period", "2024-01-01");
Assert.Equal(100, january.GetProperty("consumption").GetDouble(), 6);
}
[Fact]
public async Task A_virtual_meter_returns_evaluated_values_with_a_status()
{
// D-45: a virtual meter used to return nothing; it now returns its formula evaluated month by month, with the
// same status semantics as any meter — and its cost by its rule (here its sources' own costs, D-39).
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
var b = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 20, 30);
var januaryOnly = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 10);
await box.MeterPriceAsync(a, 0.30, D(2024, 1, 1));
await box.MeterPriceAsync(b, 0.10, D(2024, 1, 1));
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
// No cost rule, so nothing but its sources' data can keep a month in the answer.
var partial = await box.VirtualAsync(type, $"m{a} + m{januaryOnly}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
using var api = new FrozenApi(fx.ConnectionString);
var client = api.Client;
var rows = (await GetJsonAsync(client, ConsumptionUrl(sum))).RootElement.EnumerateArray().ToList();
Assert.Equal([120d, 80d], rows.Select(r => Math.Round(r.GetProperty("consumption").GetDouble(), 6)));
Assert.All(rows, r => AssertString(r, "status", "Available"));
Assert.All(rows, r => AssertString(r, "kind", "Consumption"));
var costs = (await GetJsonAsync(client, CostUrl(sum))).RootElement.EnumerateArray().ToList();
Assert.Equal([(100 * 0.30) + (20 * 0.10), (50 * 0.30) + (30 * 0.10)], costs.Select(r => Math.Round(r.GetProperty("cost").GetDouble(), 6)));
// A source without February makes February unknown, never "a + 0" (strict, D-27) — reported, not left out.
var strict = (await GetJsonAsync(client, ConsumptionUrl(partial))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, strict.Count);
Assert.Equal(110, strict[0].GetProperty("consumption").GetDouble(), 6);
AssertString(strict[1], "status", "Missing");
AssertString(strict[1], "issue", "MissingSource");
Assert.Equal(0, strict[1].GetProperty("consumption").GetDouble());
// A definition that cannot be evaluated says so in every month.
var invalid = (await GetJsonAsync(client, ConsumptionUrl(broken))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, invalid.Count);
Assert.All(invalid, r => AssertString(r, "status", "Invalid"));
Assert.All(invalid, r => AssertString(r, "issue", "InvalidDefinition"));
}
[Fact]
public async Task A_meter_that_is_not_costed_or_cannot_be_evaluated_never_reads_as_a_priced_zero()
{
// A-16: costAvailability exists so the API never turns an unknown cost into a priced 0. A dependency loop, a
// ratio that divides by zero and a generation meter have no cost — each says so (costStatus, costAvailability and
// the additive costRule/notCosted), while cost itself stays the number 0 (D-45).
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
var b = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 20, 0);
var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2024, 1, 1), 250, 200);
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
var one = await box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
var two = await box.VirtualAsync(type, $"m{one}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
await using (var db = fx.CreateContext())
{
var meter = await db.Meters.FindAsync(one);
meter!.Meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{a} + m{two}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity));
await db.SaveChangesAsync();
}
var ratio = await box.VirtualAsync(type, $"m{a} / m{b}", QuantityKind.Indicator, "kWh/kWh", VirtualCostRule.None);
using var api = new FrozenApi(fx.ConnectionString);
var client = api.Client;
// The loop: every month is invalid, and so is its cost — never "Priced, Available".
var loop = (await GetJsonAsync(client, CostUrl(one))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, loop.Count);
Assert.All(loop, row =>
{
AssertString(row, "status", "Invalid");
Assert.Equal(0, row.GetProperty("cost").GetDouble());
AssertString(row, "costStatus", "NotPriced");
AssertString(row, "costAvailability", "Invalid");
AssertString(row, "costRule", "None");
AssertString(row, "notCosted", "NotEvaluable");
});
// The ratio is never costed (D-26); February divides by zero and is invalid.
var indicator = (await GetJsonAsync(client, CostUrl(ratio))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, indicator.Count);
Assert.All(indicator, row => AssertString(row, "costStatus", "NotPriced"));
AssertString(indicator[1], "status", "Invalid");
AssertString(indicator[1], "costAvailability", "Invalid");
AssertString(indicator[0], "notCosted", "NoCostRule");
// Generation is never billed (D-34): a known quantity, and no cost — not a priced one.
var generation = (await GetJsonAsync(client, CostUrl(pv))).RootElement.EnumerateArray().ToList();
Assert.Equal(2, generation.Count);
Assert.All(generation, row =>
{
AssertString(row, "costStatus", "NotPriced");
AssertString(row, "costAvailability", "Available");
AssertString(row, "costRule", "None");
AssertString(row, "notCosted", "Generation");
});
// A priced meter keeps its rule and names no reason.
var priced = (await GetJsonAsync(client, CostUrl(a))).RootElement.EnumerateArray().ToList();
Assert.All(priced, row =>
{
AssertString(row, "costStatus", "Priced");
AssertString(row, "notCosted", "None");
Assert.NotEqual("None", row.GetProperty("costRule").GetString());
});
}
[Fact]
public async Task Dashboard_summary_keeps_its_fields_and_types()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
using var summary = await GetJsonAsync(client, "/api/v1/dashboard/summary");
var root = summary.RootElement;
AssertProperty(root, "asOf", JsonValueKind.String);
AssertProperty(root, "latestMonthCost", JsonValueKind.Number);
foreach (var name in new[] { "month", "year" })
{
var kpi = root.GetProperty(name);
AssertProperty(kpi, "current", JsonValueKind.Number);
AssertProperty(kpi, "previous", JsonValueKind.Number);
AssertProperty(kpi, "delta", JsonValueKind.Number);
AssertProperty(kpi, "deltaPercent", JsonValueKind.Number);
AssertProperty(kpi, "direction", JsonValueKind.Number);
// Added (D-45): whether the percentage means anything. Against a zero baseline it does not.
AssertProperty(kpi, "deltaPercentApplicable", JsonValueKind.False);
}
// Added (D-45): the month the latest cost is for — none on an empty instance.
AssertProperty(root, "latestMonth", JsonValueKind.Null);
}
[Fact]
public async Task The_summary_follows_the_bill_and_names_its_latest_month()
{
// The legacy calendar windows (this month and year to now, against the whole previous ones), priced as the bill:
// the grid import is billed, the household meter behind it is not (D-34), and a manual cost counts once.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
await box.MonthlyReadingsAsync(grid, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 19), 0]); // 2025: 1,200; 2026 to July: 700; August: 0
await box.ReadingsAsync(grid, (Midnight(2026, 9, 10), 1950)); // September to date: 50
await box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(300d, 12)]);
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
await box.ManualCostAsync(D(2026, 9, 5), 7);
using var api = new FrozenApi(fx.ConnectionString);
var client = api.Client;
var root = (await GetJsonAsync(client, "/api/v1/dashboard/summary")).RootElement;
AssertString(root, "asOf", "2026-09-19");
var month = root.GetProperty("month");
Assert.Equal(12, month.GetProperty("current").GetDouble(), 6); // 50 × 0.10 + 7
Assert.Equal(0, month.GetProperty("previous").GetDouble(), 6); // August: a priced zero
Assert.Equal(0, month.GetProperty("deltaPercent").GetDouble());
Assert.Equal(JsonValueKind.False, month.GetProperty("deltaPercentApplicable").ValueKind);
Assert.Equal(1, month.GetProperty("direction").GetInt32());
var year = root.GetProperty("year");
Assert.Equal(82, year.GetProperty("current").GetDouble(), 6); // 750 × 0.10 + 7
Assert.Equal(120, year.GetProperty("previous").GetDouble(), 6); // the grid's 1,200 kWh; not the house's 3,600
Assert.Equal(-38, year.GetProperty("delta").GetDouble(), 6);
Assert.Equal(-38d / 120 * 100, year.GetProperty("deltaPercent").GetDouble(), 6);
Assert.Equal(JsonValueKind.True, year.GetProperty("deltaPercentApplicable").ValueKind);
Assert.Equal(-1, year.GetProperty("direction").GetInt32());
// The latest month with data rests on the grid meter and the manual cost alike (D-19).
Assert.Equal(12, root.GetProperty("latestMonthCost").GetDouble(), 6);
var latest = root.GetProperty("latestMonth");
AssertString(latest, "period", "2026-09-01");
AssertString(latest, "basis", "Both");
}
private static string ConsumptionUrl(int meter) => Url("consumption", meter);
private static string CostUrl(int meter) => Url("cost", meter);
/// <summary>January and February 2024 by Berlin's local midnights, so there is no partial edge hour.</summary>
private static string Url(string endpoint, int meter) =>
$"/api/v1/{endpoint}?meter={meter}&from={Uri.EscapeDataString("2024-01-01T00:00:00+01:00")}&to={Uri.EscapeDataString("2024-03-01T00:00:00+01:00")}";
private static void AssertProperty(JsonElement element, string name, JsonValueKind kind)
{
Assert.True(element.TryGetProperty(name, out var value), $"missing property '{name}' in {element}");
Assert.Equal(kind, value.ValueKind);
}
private static void AssertString(JsonElement element, string name, string expected)
{
AssertProperty(element, name, JsonValueKind.String);
Assert.Equal(expected, element.GetProperty(name).GetString());
}
private static async Task<JsonDocument> GetJsonAsync(HttpClient client, string url)
{
using var response = await client.GetAsync(new Uri(url, UriKind.Relative));
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
/// <summary>A kWh meter with imported month rows for January (100) and February (150) and a 0.30 price on the meter.</summary>
private async Task<int> CreateMeterWithTwoMonthsAsync()
{
await using var db = fx.CreateContext();
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter { Name = $"contract-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
db.Readings.AddRange(
new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), Value = 100, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel },
new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), Value = 150, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
db.Tariffs.Add(new Tariff
{
ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice,
Value = 0.30, Unit = "EUR/kWh", ValidFrom = new DateOnly(2023, 1, 1),
});
await db.SaveChangesAsync();
await using var tx = await db.Database.BeginTransactionAsync();
var normalization = new NormalizationService(db, Core.Normalization.NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new Infrastructure.Options.MeterVaultOptions { TimeZone = "Europe/Berlin" }));
await normalization.RecomputeMeterAsync(meter.Id, null);
await db.SaveChangesAsync();
await tx.CommitAsync();
return meter.Id;
}
private async Task CleanupAsync(int meterId)
{
await using var db = fx.CreateContext();
await db.Tariffs.Where(t => t.ScopeType == TariffScope.Meter && t.ScopeId == meterId).ExecuteDeleteAsync();
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
/// <summary>The app on the frozen clock of <see cref="CostSandbox.Now"/>, with an API key; disposes all it created.</summary>
private sealed class FrozenApi : IDisposable
{
private readonly MeterVaultAppFactory _root;
private readonly WebApplicationFactory<Program> _app;
public FrozenApi(string connectionString)
{
_root = new MeterVaultAppFactory(connectionString);
_app = _root.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(Now))));
Client = _app.CreateClient();
Client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
}
public HttpClient Client { get; }
public void Dispose()
{
Client.Dispose();
_app.Dispose();
_root.Dispose();
}
}
}
@@ -0,0 +1,466 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// The cost engine against real rollups (D-34 D-41, brief §6.2, §11): missing versus free prices, gaps and unit
/// mismatches, standing charges once per scope, prices month by month whatever the bucket size, separately billed
/// subsections, virtual cost rules, feed-in credits, manual costs booked once, and the latest period with data.
/// Every test builds its own energy types and meters (never the portfolio, which other tests share) and removes them.
/// </summary>
[Collection("Timescale")]
public sealed class CostReaderTests(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 A_missing_tariff_is_not_priced_and_a_zero_tariff_is_a_valid_zero()
{
// Brief §11 "Missing versus free tariff": a valid quantity without a required price has no cost; an explicit
// zero tariff costs exactly zero.
var (missingType, freeType) = (await _box.TypeAsync(), await _box.TypeAsync());
var missing = await _box.MonthlyAsync(missingType, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var free = await _box.MonthlyAsync(freeType, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
await _box.TypePriceAsync(freeType, 0, D(2026, 1, 1));
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
var unpriced = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(missingType), period) { Bucket = BucketSize.Month });
Assert.Equal(CostStatus.NotPriced, unpriced.Total.Status);
Assert.Null(unpriced.Total.Cost);
Assert.True(unpriced.Total.IncludesNotPriced);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, missingType, missing, D(2026, 1, 1), D(2026, 2, 1)),
Assert.Single(unpriced.MissingPrices));
var attention = Assert.Single(unpriced.Attention);
Assert.Equal((CostAttentionKind.MissingPrice, (int?)missing), (attention.Kind, attention.MeterId));
Assert.False(attention.Price!.IsCredit);
// The quantity is still there; only its price is missing.
var line = Assert.Single(unpriced.Lines);
Assert.Equal([100d, 80d], line.Quantities);
Assert.Equal(180, line.TotalQuantity);
Assert.Equal(BillingBasis.Use, Assert.Single(unpriced.EnergyTypes).Basis);
var priced = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(freeType), period) { Bucket = BucketSize.Month });
CostAssert.Priced(0, priced.Total);
Assert.All(priced.Buckets, b => CostAssert.Priced(0, b));
Assert.Empty(priced.MissingPrices);
Assert.Equal(free, Assert.Single(priced.Lines).MeterId);
Assert.Equal("EUR", priced.Currency);
}
[Fact]
public async Task A_gap_in_a_price_history_makes_those_months_unavailable()
{
var type = await _box.TypeAsync();
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100, 100);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1), to: D(2026, 1, 31));
await _box.TypePriceAsync(type, 0.40, D(2026, 3, 1));
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 1, 1), D(2026, 4, 30))) { Bucket = BucketSize.Month });
Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], result.Buckets.Select(b => b.Status));
Assert.Null(result.Buckets[1].Cost);
CostAssert.Cost(30, result.Buckets[0]);
Assert.Equal(CostStatus.Partial, result.Total.Status);
CostAssert.Cost(110, result.Total);
var gap = Assert.Single(result.MissingPrices);
Assert.Equal((CostStatus.PriceGap, TariffScope.EnergyType, (int?)type, (int?)meter, D(2026, 2, 1), D(2026, 2, 1)),
(gap.Reason, gap.Scope, gap.ScopeId, gap.MeterId, gap.FirstMonth, gap.LastMonth));
Assert.Contains(result.Attention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price == gap);
}
[Fact]
public async Task A_price_in_another_unit_is_a_unit_mismatch_and_a_currency_follows_the_options()
{
var type = await _box.TypeAsync();
await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100);
var tariff = await _box.TypePriceAsync(type, 5, D(2026, 1, 1), unit: "EUR/m3");
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Month });
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Null(result.Total.Cost);
var mismatch = Assert.Single(result.MissingPrices);
Assert.Equal((CostStatus.UnitMismatch, (int?)tariff), (mismatch.Reason, mismatch.TariffId));
// D-43: the instance currency is the options'; a "EUR" price does not fit a CHF instance.
var swiss = await _box.Reader("CHF").ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Month });
Assert.Equal("CHF", swiss.Currency);
Assert.Equal(CostStatus.UnitMismatch, swiss.Total.Status);
}
[Fact]
public async Task A_standing_charge_accrues_once_per_scope_and_a_meter_fee_on_its_meter()
{
// Two consumption roots of one type: the type's standing charge is one row, never one per meter (D-40).
var type = await _box.TypeAsync();
var first = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
var second = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 31, "EUR/month", D(2026, 1, 1));
await _box.TariffAsync(TariffScope.Meter, second, TariffComponent.BasePrice, 5, "EUR/month", D(2026, 1, 1));
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
var row = Assert.Single(bill.StandingCharges);
Assert.Equal((TariffScope.EnergyType, (int?)type), (row.Scope, row.ScopeId));
CostAssert.Priced(93, row.Total);
Assert.Equal(D(2026, 1, 1), row.Service!.FirstDay);
Assert.All(row.Buckets, b => CostAssert.Priced(31, b));
// 600 kWh × 0.10 + 3 × 31 € + the second meter's own fee 3 × 5 €, on its line.
CostAssert.Priced(60 + 93 + 15, bill.Total);
Assert.Null(bill.Lines.Single(l => l.MeterId == first).Total.StandingCharge);
Assert.Equal(15, bill.Lines.Single(l => l.MeterId == second).Total.StandingCharge!.Value, 6);
// A meter's own cost carries its own fee, never the type's charge.
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(second), quarter) { Bucket = BucketSize.Month });
Assert.Empty(own.StandingCharges);
CostAssert.Priced(30 + 15, own.Total);
Assert.Equal(MeterCostRule.BillLine, own.Meter!.Rule);
// A standing charge accrues per day: half of February is half of February's charge.
var half = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 2, 1), D(2026, 2, 14))) { Bucket = BucketSize.Day });
CostAssert.Cost(31 * 14 / 28d, Assert.Single(half.StandingCharges).Total);
}
[Fact]
public async Task The_bucket_size_never_changes_a_total_and_every_part_is_priced_in_its_month()
{
// D-36: a year with a price change on 1 July, at daily resolution. Year, month, week and day buckets all
// price January to June at 0.30 and July to December at 0.40 — a week across the change is split.
var type = await _box.TypeAsync();
await _box.DailyAsync(type, D(2025, 1, 1), D(2026, 1, 1), perDay: 10);
await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1), to: D(2025, 6, 30));
await _box.TypePriceAsync(type, 0.40, D(2025, 7, 1));
const double expected = (181 * 10 * 0.30) + (184 * 10 * 0.40);
foreach (var (size, count) in new[] { (BucketSize.Year, 1), (BucketSize.Month, 12), (BucketSize.Week, 53), (BucketSize.Day, 365) })
{
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Year(2025)) { Bucket = size });
Assert.Equal(count, result.Buckets.Count);
CostAssert.Priced(expected, result.Total, 1e-6);
Assert.Equal(expected, result.Buckets.Sum(b => b.Cost!.Value), 6);
Assert.All(result.Buckets, b => Assert.Equal(BucketStatus.Available, b.Availability));
if (size == BucketSize.Week)
{
// Monday 30 June Sunday 6 July: one day at June's price, six at July's.
var index = result.Plan.Buckets.ToList().FindIndex(b => b.FirstDay == D(2025, 6, 30));
CostAssert.Cost((10 * 0.30) + (60 * 0.40), result.Buckets[index]);
}
}
}
[Fact]
public async Task A_monthly_import_prices_its_month_although_its_days_are_unresolved()
{
var type = await _box.TypeAsync();
await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var daily = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 1)) { Bucket = BucketSize.Day });
Assert.Equal(31, daily.Buckets.Count);
Assert.All(daily.Buckets, b =>
{
Assert.Null(b.Cost);
Assert.Equal(BucketStatus.Unresolved, b.Availability);
});
CostAssert.Priced(10, daily.Total);
Assert.Equal(BucketStatus.Available, daily.Total.Availability);
// Auto chooses a size the data resolves, so the chart has values.
var auto = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Custom(D(2026, 1, 1), D(2026, 2, 28))));
Assert.Equal(BucketSize.Month, auto.Plan.Size);
Assert.Equal([10d, 8d], auto.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
}
[Fact]
public async Task A_retired_meter_is_a_known_zero_on_the_bill_and_missing_on_its_own_page()
{
// D-24: outside its service period a meter contributes a known zero to the bill, so a meter replaced at the end
// of January leaves the quarter's bill complete; its own page still shows no data after it was retired.
var type = await _box.TypeAsync();
var retired = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), retiredAt: D(2026, 1, 31));
await _box.MonthlyReadingsAsync(retired, D(2026, 1, 1), 100);
var successor = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1));
await _box.MonthlyReadingsAsync(successor, D(2026, 2, 1), 80, 90);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
Assert.Equal([10d, 8d, 9d], bill.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
Assert.All(bill.Buckets, b => Assert.Equal(BucketStatus.Available, b.Availability));
CostAssert.Priced(27, bill.Total);
Assert.Equal(BucketStatus.Available, bill.Total.Availability);
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(retired), quarter) { Bucket = BucketSize.Month });
CostAssert.Priced(10, own.Total);
Assert.Equal(BucketStatus.Partial, own.Total.Availability);
Assert.Equal(BucketStatus.Missing, own.Buckets[1].Availability);
}
[Fact]
public async Task An_unpriced_line_does_not_coarsen_the_automatic_buckets()
{
// A delta meter reporting once a quarter resolves only years; it has no tariff, so it has no cost to chart and
// must not turn the priced monthly meter's chart into one yearly bar.
var type = await _box.TypeAsync();
var monthly = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100, 100, 100, 100);
await _box.MeterPriceAsync(monthly, 0.10, D(2026, 1, 1));
var quarterly = await _box.MeterAsync(type, MeterMode.DirectDelta, "kWh", D(2026, 1, 1));
await _box.ReadingsAsync(quarterly, (Midnight(2026, 1, 1), 0), (Midnight(2026, 4, 1), 300), (Midnight(2026, 7, 1), 300));
var half = Custom(D(2026, 1, 1), D(2026, 6, 30));
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), half));
Assert.Equal(BucketSize.Month, result.Plan.Size);
Assert.Equal(6, result.Buckets.Count);
Assert.All(result.Buckets, b => Assert.Equal(10, b.Cost!.Value, 6));
Assert.Contains(result.MissingPrices, m => m.MeterId == quarterly && m.Reason == CostStatus.NotPriced);
}
[Fact]
public async Task A_separately_billed_subsection_is_priced_at_its_own_price_out_of_its_parent()
{
// D-35: house 300 kWh a month, a heat pump below it 100 kWh at its own 0.22 — billed (300 100) × 0.30 + 100 × 0.22.
var type = await _box.TypeAsync();
var house = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 300, 300);
var pump = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100);
await _box.LinkAsync(house, pump);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
await _box.MeterPriceAsync(pump, 0.22, D(2026, 1, 1));
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
CostAssert.Priced((400 * 0.30) + (200 * 0.22), bill.Total);
var houseLine = bill.Lines.Single(l => l.MeterId == house);
Assert.Equal(BillLineKind.UnitPrice, houseLine.Kind);
Assert.Equal([200d, 200d], houseLine.Quantities);
Assert.Equal(pump, Assert.Single(houseLine.Deductions).MeterId);
var pumpLine = bill.Lines.Single(l => l.MeterId == pump);
Assert.Equal(BillLineKind.OwnPrice, pumpLine.Kind);
CostAssert.Priced(44, pumpLine.Total);
var pumpCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(pump), period) { Bucket = BucketSize.Month });
Assert.Equal((MeterCostRule.BillLine, true), (pumpCost.Meter!.Rule, pumpCost.Meter.OnBill));
CostAssert.Priced(44, pumpCost.Total);
var houseCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(house), period) { Bucket = BucketSize.Month });
CostAssert.Priced(120, houseCost.Total);
}
[Fact]
public async Task Virtual_meters_are_costed_by_their_named_rule()
{
// D-39: a pure sum adds its sources' costs at their own prices; a linear formula with ownQuantity prices its
// quantity; a difference has no cost. The two rules differ when the sources are priced differently.
var type = await _box.TypeAsync();
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 150, 120);
var generator = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 40, 50);
await _box.TypePriceAsync(type, 0.25, D(2026, 1, 1));
await _box.MeterPriceAsync(a, 0.20, D(2026, 1, 1));
await _box.MeterPriceAsync(b, 0.30, D(2026, 1, 1));
var sources = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var own = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
var difference = await _box.VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
async Task<CostAnalysis> CostOf(int meter) =>
await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), period) { Bucket = BucketSize.Month });
var summed = await CostOf(sources);
Assert.Equal(MeterCostRule.SourceCosts, summed.Meter!.Rule);
Assert.False(summed.Meter.OnBill);
Assert.Equal([a, b], summed.Meter.SourceIds);
CostAssert.Priced((180 * 0.20) + (270 * 0.30), summed.Total);
Assert.All(summed.Lines, l => Assert.Equal(sources, l.ForMeterId));
var repriced = await CostOf(own);
Assert.Equal(MeterCostRule.OwnQuantity, repriced.Meter!.Rule);
CostAssert.Priced(450 * 0.25, repriced.Total);
Assert.Equal(own, Assert.Single(repriced.Lines).MeterId);
var none = await CostOf(difference);
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.NoCostRule), (none.Meter!.Rule, none.Meter.NotCosted));
Assert.Empty(none.Lines);
Assert.Null(none.Total.Cost);
var generation = await CostOf(generator);
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.Generation), (generation.Meter!.Rule, generation.Meter.NotCosted));
// The sources are the type's bill; the virtual views never add to it.
var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
Assert.Equal([a, b], bill.Lines.Select(l => l.MeterId).Order());
CostAssert.Priced((180 * 0.20) + (270 * 0.30), bill.Total);
}
[Fact]
public async Task A_virtual_meter_counted_by_an_override_is_billed_by_its_cost_rule()
{
// D-23 + D-39: "always" puts a virtual pure sum on the bill in place of its sources; the bill then prices it by
// its rule — its sources at their own prices, or its own quantity — and never both. With the rule "none" it is
// left out and named.
const string always = "{\"totals\":\"always\"}";
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
async Task<(int A, int B, short Type)> SourcesAsync()
{
var type = await _box.TypeAsync();
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 150, 120);
await _box.TypePriceAsync(type, 0.25, D(2026, 1, 1));
await _box.MeterPriceAsync(a, 0.20, D(2026, 1, 1));
await _box.MeterPriceAsync(b, 0.30, D(2026, 1, 1));
return (a, b, type);
}
async Task<CostAnalysis> BillOf(short type) =>
await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
var (a1, b1, bySources) = await SourcesAsync();
var summed = await _box.VirtualAsync(bySources, $"m{a1} + m{b1}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts, always);
var sourcesBill = await BillOf(bySources);
Assert.Equal([a1, b1], sourcesBill.Lines.Select(l => l.MeterId).Order());
Assert.All(sourcesBill.Lines, l => Assert.Equal(summed, l.ForMeterId));
CostAssert.Priced((180 * 0.20) + (270 * 0.30), sourcesBill.Total);
var summedCost = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(summed), period) { Bucket = BucketSize.Month });
Assert.Equal((MeterCostRule.SourceCosts, true), (summedCost.Meter!.Rule, summedCost.Meter.OnBill));
var (a2, b2, byQuantity) = await SourcesAsync();
var own = await _box.VirtualAsync(byQuantity, $"m{a2} + m{b2}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity, always);
var ownBill = await BillOf(byQuantity);
Assert.Equal(own, Assert.Single(ownBill.Lines).MeterId);
CostAssert.Priced(450 * 0.25, ownBill.Total);
var (a3, b3, uncosted) = await SourcesAsync();
var none = await _box.VirtualAsync(uncosted, $"m{a3} + m{b3}", QuantityKind.Consumption, "kWh", VirtualCostRule.None, always);
var noneBill = await BillOf(uncosted);
Assert.Empty(noneBill.Lines);
Assert.Null(noneBill.Total.Cost);
Assert.Equal(none, Assert.Single(noneBill.Attention, x => x.Kind == CostAttentionKind.VirtualNotCosted).MeterId);
}
[Fact]
public async Task Export_is_credited_at_the_feed_in_price_and_a_missing_one_is_an_optional_credit()
{
var type = await _box.TypeAsync();
var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var export = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport));
await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 300, 300);
await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50, 50);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
var period = Custom(D(2026, 1, 1), D(2026, 2, 28));
var noCredit = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
CostAssert.Priced(180, noCredit.Total);
var credit = Assert.Single(noCredit.MissingPrices);
Assert.True(credit.IsCredit);
Assert.Equal((CostStatus.NotPriced, (int?)export), (credit.Reason, credit.MeterId));
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1));
var credited = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = BucketSize.Month });
CostAssert.Priced(180 - 8, credited.Total);
Assert.Equal(180, credited.Total.Charges!.Value, 6);
Assert.Equal(8, credited.Total.FeedInCredit!.Value, 6);
Assert.Equal(BillLineKind.FeedIn, credited.Lines.Single(l => l.MeterId == export).Kind);
Assert.Empty(credited.MissingPrices);
}
[Fact]
public async Task Manual_costs_are_booked_once_on_their_start_day_wherever_they_belong()
{
// D-41: a manual cost on a meter belongs to that meter, its type and its categories — once each — and one
// dated after today is reported, not booked.
var type = await _box.TypeAsync();
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var booked = await _box.ManualCostAsync(D(2026, 2, 10), 50, meterId: meter);
var later = await _box.ManualCostAsync(D(2026, 10, 1), 20, meterId: meter);
var category = await _box.CategoryAsync($"cost-{Guid.NewGuid():N}", 90, meters: [meter]);
var year = Year(2026);
foreach (var scope in new[] { CostScope.ForEnergyType(type), CostScope.ForMeter(meter), CostScope.ForCategory(category) })
{
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(scope, year) { Bucket = BucketSize.Month });
CostAssert.Priced(30 + 50, result.Total);
var booking = Assert.Single(result.ManualCosts.Bookings);
Assert.Equal((booked, D(2026, 2, 10), 1, 50d), (booking.ManualCostId, booking.Day, booking.BucketIndex, booking.Amount));
CostAssert.Priced(50, result.ManualCosts.Buckets[1]);
Assert.Equal([later], result.ManualCosts.AfterTodayIds);
Assert.Equal([later], Assert.Single(result.Attention, a => a.Kind == CostAttentionKind.ManualCostAfterToday).ManualCostIds);
}
var figure = (await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(category), year) { Bucket = BucketSize.Month })).Category!;
Assert.Contains(booked, figure.ManualCostIds);
Assert.Equal([meter], figure.Cover.BilledMeterIds);
}
[Fact]
public async Task The_latest_period_with_data_includes_manual_costs()
{
// D-19: metered data ends in February; a manual cost on the meter in April makes April the latest month.
var type = await _box.TypeAsync();
var meter = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var metered = await _box.Reader().GetAvailabilityAsync(CostScope.ForEnergyType(type), Now);
Assert.Equal(new LatestPeriod(D(2026, 2, 1), LatestPeriodBasis.Meters), metered.Latest);
await _box.ManualCostAsync(D(2026, 4, 10), 12, meterId: meter);
var withManual = await _box.Reader().GetAvailabilityAsync(CostScope.ForEnergyType(type), Now);
Assert.Equal(new LatestPeriod(D(2026, 4, 1), LatestPeriodBasis.Manual), withManual.Latest);
Assert.Equal((D(2026, 1, 1), D(2026, 4, 10)), (withManual.Range!.FirstDay, withManual.Range.LastDay));
// The priced result reports the same, whatever period it prices.
var result = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), Preset(PeriodPreset.MonthToDate)));
Assert.Equal(withManual.Latest, result.Availability.Latest);
// A month with both is both.
await _box.ManualCostAsync(D(2026, 2, 3), 5, meterId: meter);
var both = await _box.Reader().GetAvailabilityAsync(CostScope.ForMeter(meter), new DateTimeOffset(2026, 3, 15, 12, 0, 0, TimeSpan.Zero));
Assert.Equal(new LatestPeriod(D(2026, 2, 1), LatestPeriodBasis.Both), both.Latest);
}
[Fact]
public async Task Unknown_scopes_and_too_many_points_are_refused_before_pricing()
{
var reader = _box.Reader();
Assert.Equal(CostRefusal.UnknownScope, (await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(-1), Month(2026, 1)))).Refusal);
Assert.Equal(CostRefusal.UnknownScope, (await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(-1), Month(2026, 1)))).Refusal);
var days = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last24Months)) { Bucket = BucketSize.Day });
Assert.Equal(CostRefusal.TooManyPoints, days.Refusal);
Assert.Equal(BucketSize.Week, days.Plan.Suggested);
Assert.Null(days.Total.Cost);
var other = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, TimeZoneInfo.Utc);
await Assert.ThrowsAsync<ArgumentException>(() => reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, other)));
}
}
@@ -11,7 +11,8 @@ namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// Reconciles the cost engine against the Wasser sheet's Kosten column (consumption × €/m³) and
/// checks category rollups and continuous-aggregate refresh (SDD §7.5, §5.4).
/// checks category rollups and the monthly consumption rollups that replaced the continuous
/// aggregates (SDD §7.5, D-17).
/// </summary>
[Collection("Timescale")]
public sealed class CostReconciliationTests(TimescaleFixture fx)
@@ -74,34 +75,30 @@ public sealed class CostReconciliationTests(TimescaleFixture fx)
}
[Fact]
public async Task Monthly_continuous_aggregate_refreshes_and_matches_base()
public async Task Monthly_rollup_equals_the_consumption_it_sums()
{
// D-17: the rollup written with the import, in the normalizer's zone (UTC here), is what month and year
// reads use — no refresh step, no lag, and the same total the consumption rows add up to.
await using var db = fx.CreateContext();
var meterId = await ImportWaterAsync(db);
// refresh_continuous_aggregate cannot run inside a transaction — use the raw connection.
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "CALL refresh_continuous_aggregate('consumption_monthly', NULL, NULL);";
await cmd.ExecuteNonQueryAsync();
}
var months = await db.ConsumptionRollupMonths.AsNoTracking()
.Where(r => r.MeterId == meterId)
.ToDictionaryAsync(r => r.Month, r => r.Amount);
var rows = await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).ToListAsync();
var byMonth = rows
.GroupBy(c => new DateOnly(c.Time.UtcDateTime.Year, c.Time.UtcDateTime.Month, 1))
.ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
double aggregated;
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText =
"SELECT sum(amount) FROM consumption_monthly WHERE meter_id = @m " +
"AND (bucket AT TIME ZONE 'Europe/Berlin')::date = DATE '2022-12-01';";
var p = cmd.CreateParameter();
p.ParameterName = "m";
p.Value = meterId;
cmd.Parameters.Add(p);
aggregated = Convert.ToDouble(await cmd.ExecuteScalarAsync());
}
Assert.Equal(14d, months[new DateOnly(2022, 12, 1)], 6); // Dez 2022 consumption
Assert.Equal(byMonth.Keys.Order(), months.Keys.Order());
Assert.All(byMonth, m => Assert.Equal(m.Value, months[m.Key], 9));
Assert.Equal(14d, aggregated, 1); // Dez 2022 consumption
// The day table holds the same December, on the day the month row is filed.
var decemberDays = await db.ConsumptionRollups.AsNoTracking()
.Where(r => r.MeterId == meterId && r.Day >= new DateOnly(2022, 12, 1) && r.Day < new DateOnly(2023, 1, 1))
.SumAsync(r => r.Amount);
Assert.Equal(14d, decemberDays, 6);
await CleanupAsync(db, meterId);
}
@@ -0,0 +1,317 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// Regressions from the cost review (R1 R6): costs that intervals longer than a month still allow, virtual source
/// costs priced the way each source's own scope is, meter fees on meters the bill has no line for, the months a grid
/// meter was not in service, and a priced consumer linked directly below the grid meter.
/// </summary>
[Collection("Timescale")]
public sealed class CostReviewFixTests(TimescaleFixture fx) : IAsyncLifetime
{
private CostSandbox _box = null!;
public Task InitializeAsync()
{
_box = new CostSandbox(fx);
return Task.CompletedTask;
}
public async Task DisposeAsync() => await _box.DisposeAsync();
// ------------------------------------------------------------------------------------------------ R5
[Fact]
public async Task A_quarterly_delta_meter_with_one_price_has_a_yearly_cost()
{
// R5 (D-36, D-14): read once a quarter, the meter cannot be cut into months, so no month has a cost. One price
// covers every month of every interval, though, so the year has one: 1,200 kWh × 0.10.
var type = await _box.TypeAsync();
var quarterly = await QuarterlyAsync(type);
await _box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
var yearly = await BillAsync(type, Year(2025), BucketSize.Year);
CostAssert.Priced(120, yearly.Total);
CostAssert.Priced(120, Assert.Single(yearly.Buckets));
var monthly = await BillAsync(type, Year(2025), BucketSize.Month);
Assert.All(monthly.Buckets, b => Assert.Null(b.Cost));
Assert.All(monthly.Buckets, b => Assert.Equal(BucketStatus.Unresolved, b.Availability));
CostAssert.Priced(120, monthly.Total);
var auto = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Year(2025)));
Assert.Equal(BucketSize.Year, auto.Plan.Size);
CostAssert.Priced(120, auto.Total);
var own = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(quarterly), Year(2025)) { Bucket = BucketSize.Year });
CostAssert.Priced(120, own.Total);
Assert.DoesNotContain(own.Attention, a => a.Kind == CostAttentionKind.PriceChangeInsideInterval);
}
[Fact]
public async Task A_price_change_inside_an_interval_leaves_the_span_unavailable_and_says_why()
{
// R5: the price rises from August; the July September interval spans both prices and cannot be split, so the
// year has no cost — never a confident one, never 0 — and the attention item names the meter.
var type = await _box.TypeAsync();
var quarterly = await QuarterlyAsync(type);
await _box.TypePriceAsync(type, 0.10, D(2025, 1, 1), to: D(2025, 7, 31));
await _box.TypePriceAsync(type, 0.20, D(2025, 8, 1));
var yearly = await BillAsync(type, Year(2025), BucketSize.Year);
Assert.Null(yearly.Total.Cost);
Assert.Equal(BucketStatus.Unresolved, yearly.Total.Availability);
var attention = Assert.Single(yearly.Attention, a => a.Kind == CostAttentionKind.PriceChangeInsideInterval);
Assert.Equal(quarterly, attention.MeterId);
Assert.Equal((D(2025, 1, 1), D(2025, 12, 1)), (attention.FirstMonth, attention.LastMonth));
}
// ------------------------------------------------------------------------------------------------ R1
[Fact]
public async Task A_sum_of_generation_meters_has_no_purchase_cost()
{
// R1 (D-34, D-39): the sources' own costs are "none (generation)", so their sum costs nothing — not 250 kWh at
// the purchase price.
var type = await _box.TypeAsync();
var s1 = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100);
var s2 = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 150);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
var sum = await _box.VirtualAsync(type, $"m{s1} + m{s2}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts);
var summed = await CostOfAsync(sum, Month(2026, 1));
Assert.Null(summed.Total.Cost);
Assert.Empty(summed.Lines);
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.Generation), (summed.Meter!.Rule, summed.Meter.NotCosted));
// A generation sum is not costed by default either.
Assert.Equal(VirtualCostRule.None, VirtualValidator.DefaultCostRule(Formula.Parse($"m{s1} + m{s2}"), QuantityKind.Generation));
}
[Fact]
public async Task An_export_view_costs_its_source_s_feed_in_credit()
{
// R1: a view over the export meter adds the export meter's own cost — a feed-in credit, not a purchase.
var type = await _box.TypeAsync();
var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var export = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport));
await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 300);
await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
await _box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1));
var view = await _box.VirtualAsync(type, $"m{export}", QuantityKind.Net, "kWh", VirtualCostRule.SourceCosts);
var own = await CostOfAsync(export, Month(2026, 1));
var viewed = await CostOfAsync(view, Month(2026, 1));
CostAssert.Priced(-4, own.Total);
CostAssert.Priced(-4, viewed.Total);
Assert.Equal(BillLineKind.FeedIn, Assert.Single(viewed.Lines).Kind);
}
// ------------------------------------------------------------------------------------------------ R2
[Fact]
public async Task Source_costs_follow_the_formula_and_never_price_a_subtrahend()
{
// R2 (D-39): a sum over a nested difference is not a sum of metered costs — pricing its tokens would add the
// subtrahend's cost (18 instead of 12). It is not costed, and says why. A repeated token counts by its weight.
var type = await _box.TypeAsync();
var a = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100);
var b = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 30);
var c = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 50);
await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
var difference = await _box.VirtualAsync(type, $"m{a} - m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
var nested = await _box.VirtualAsync(type, $"m{difference} + m{c}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var repeated = await _box.VirtualAsync(type, $"m{a} + m{a} - m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var overDifference = await CostOfAsync(nested, Month(2026, 1));
Assert.Null(overDifference.Total.Cost);
Assert.Empty(overDifference.Lines);
Assert.Equal((MeterCostRule.None, MeterNotCostedReason.SourcesNotPureSum), (overDifference.Meter!.Rule, overDifference.Meter.NotCosted));
var weighted = await CostOfAsync(repeated, Month(2026, 1));
CostAssert.Priced(13, weighted.Total);
Assert.Equal([a, b], weighted.Lines.Select(l => l.MeterId).Order());
// Nested pure sums are expanded to their sources, each once.
var inner = await _box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var outer = await _box.VirtualAsync(type, $"m{inner} + m{c}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
CostAssert.Priced(18, (await CostOfAsync(outer, Month(2026, 1))).Total);
}
// ------------------------------------------------------------------------------------------------ R3
[Fact]
public async Task A_meter_fee_on_a_meter_without_a_bill_line_is_still_charged_on_its_meter()
{
// R3 (D-40): grid 100 kWh a month, the house behind it 150 kWh, a PV meter; fees of 2 €/month on the PV meter
// and 5 €/month on the house. The bill prices the grid import, and still charges both fees — each as its own
// row on its meter.
var type = await _box.TypeAsync();
var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var house = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 100, 100, 100);
await _box.MonthlyReadingsAsync(house, D(2026, 1, 1), 150, 150, 150);
await _box.LinkAsync(grid, house);
var pv = await _box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 40, 40, 40);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
await _box.TariffAsync(TariffScope.Meter, pv, TariffComponent.BasePrice, 2, "EUR/month", D(2026, 1, 1));
await _box.TariffAsync(TariffScope.Meter, house, TariffComponent.BasePrice, 5, "EUR/month", D(2026, 1, 1));
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
var bill = await BillAsync(type, quarter, BucketSize.Month);
CostAssert.Priced(90 + 6 + 15, bill.Total);
Assert.Equal([grid], bill.Lines.Select(l => l.MeterId));
Assert.Equal(
[(TariffScope.Meter, (int?)house, 15d), (TariffScope.Meter, (int?)pv, 6d)],
bill.StandingCharges.Select(r => (r.Scope, r.ScopeId, Math.Round(r.Total.Cost!.Value, 6))).OrderBy(r => r.Item2));
var pvOwn = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(pv), quarter) { Bucket = BucketSize.Month });
CostAssert.Priced(6, pvOwn.Total);
var houseOwn = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(house), quarter) { Bucket = BucketSize.Month });
CostAssert.Priced(135 + 15, houseOwn.Total);
// The portfolio's composition reconciles to the bill, the fees included.
var portfolio = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), quarter) { Bucket = BucketSize.Month });
CostAssert.Priced(111, portfolio.EnergyTypes.Single(t => t.EnergyTypeId == type).Total);
}
// ------------------------------------------------------------------------------------------------ R4
[Fact]
public async Task Months_before_the_grid_meter_was_installed_are_not_a_confident_zero_bill()
{
// R4 (D-34 + D-24): the house was measured from January, the grid meter only installed on 1 April. Before then
// the grid meter's known zero must not stand in for the bill: those months are unavailable, and say why.
var type = await _box.TypeAsync();
var house = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
await _box.MonthlyReadingsAsync(house, D(2025, 1, 1), 100, 100, 100, 100, 100, 100);
var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 4, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await _box.MonthlyReadingsAsync(grid, D(2025, 4, 1), 60, 60, 60);
await _box.LinkAsync(grid, house);
await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
var q1 = await BillAsync(type, Custom(D(2025, 1, 1), D(2025, 3, 31)), BucketSize.Month);
Assert.Null(q1.Total.Cost);
Assert.NotEqual(BucketStatus.Available, q1.Total.Availability);
Assert.All(q1.Buckets, b => Assert.Null(b.Cost));
var gap = Assert.Single(q1.Attention, a => a.Kind == CostAttentionKind.BillingBasisGap);
Assert.Equal((grid, D(2025, 1, 1), D(2025, 3, 1)), (gap.MeterId!.Value, gap.FirstMonth, gap.LastMonth));
var q2 = await BillAsync(type, Custom(D(2025, 4, 1), D(2025, 6, 30)), BucketSize.Month);
CostAssert.Priced(54, q2.Total);
Assert.DoesNotContain(q2.Attention, a => a.Kind == CostAttentionKind.BillingBasisGap);
var half = await BillAsync(type, Custom(D(2025, 1, 1), D(2025, 6, 30)), BucketSize.Month);
Assert.Equal(54, half.Total.Cost!.Value, 6);
Assert.Equal(BucketStatus.Partial, half.Total.Availability);
}
[Fact]
public async Task Months_after_the_grid_meter_retired_without_a_successor_are_not_a_confident_zero_bill()
{
var type = await _box.TypeAsync();
var house = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
await _box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 12)]);
var grid = await _box.MeterAsync(
type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport), retiredAt: D(2025, 6, 30));
await _box.MonthlyReadingsAsync(grid, D(2025, 1, 1), 60, 60, 60, 60, 60, 60);
await _box.LinkAsync(grid, house);
await _box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
var h1 = await BillAsync(type, Custom(D(2025, 1, 1), D(2025, 6, 30)), BucketSize.Month);
CostAssert.Priced(108, h1.Total);
var h2 = await BillAsync(type, Custom(D(2025, 7, 1), D(2025, 12, 31)), BucketSize.Month);
Assert.Null(h2.Total.Cost);
var gap = Assert.Single(h2.Attention, a => a.Kind == CostAttentionKind.BillingBasisGap);
Assert.Equal((grid, D(2025, 7, 1), D(2025, 12, 1)), (gap.MeterId!.Value, gap.FirstMonth, gap.LastMonth));
}
// ------------------------------------------------------------------------------------------------ R6
[Fact]
public async Task A_priced_consumer_linked_below_the_grid_meter_is_billed_at_its_own_price()
{
// R6 (D-35, Kaskade): grid 300 kWh a month, a heat pump behind it 100 kWh at its own 0.22, linked grid → pump:
// 2 × ((300 100) × 0.30 + 100 × 0.22) = 164, not the whole import at 0.30.
var type = await _box.TypeAsync();
var grid = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await _box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 300, 300);
var pump = await _box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100);
await _box.LinkAsync(grid, pump);
await _box.TypePriceAsync(type, 0.30, D(2026, 1, 1));
await _box.MeterPriceAsync(pump, 0.22, D(2026, 1, 1));
var bill = await BillAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)), BucketSize.Month);
CostAssert.Priced(164, bill.Total);
Assert.Equal([200d, 200d], bill.Lines.Single(l => l.MeterId == grid).Quantities);
Assert.Equal(BillLineKind.OwnPrice, bill.Lines.Single(l => l.MeterId == pump).Kind);
Assert.DoesNotContain(bill.Attention, a => a.Kind == CostAttentionKind.BillingConfiguration);
}
// ------------------------------------------------------------------------------------------------ acceptance review
[Fact]
public async Task A_category_whose_members_price_nothing_says_why_instead_of_reading_empty()
{
// D-39/D-42 keep a calculated view out of a category's cost; brief §4.3 asks for an explained result, not an empty
// one. A category holding only a costed consumption view (sourceCosts) prices nothing and says which members
// add nothing; one that also holds its metered source prices the source and says nothing.
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 viewOnly = await _box.CategoryAsync($"view-only-{Guid.NewGuid():N}", 93, meters: [view]);
var withSource = await _box.CategoryAsync($"with-source-{Guid.NewGuid():N}", 94, meters: [a, view]);
var period = Custom(D(2025, 1, 1), D(2025, 2, 28));
// The view has a cost of its own: its source's.
CostAssert.Priced(54, (await CostOfAsync(view, period)).Total);
var alone = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(viewOnly), period) { Bucket = BucketSize.Month });
Assert.Null(alone.Total.Cost);
var note = Assert.Single(alone.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing);
Assert.Equal(viewOnly, note.CategoryId);
Assert.Equal([view], note.MeterIds);
Assert.Equal(view, note.MeterId);
var both = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(withSource), period) { Bucket = BucketSize.Month });
CostAssert.Priced(54, both.Total);
Assert.DoesNotContain(both.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing);
// The portfolio with its categories names it too, for the Overview.
var portfolio = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, period) { Bucket = BucketSize.Month, IncludeCategories = true });
Assert.Contains(portfolio.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing && x.CategoryId == viewOnly);
Assert.DoesNotContain(portfolio.Attention, x => x.Kind == CostAttentionKind.CategoryPricesNothing && x.CategoryId == withSource);
}
// ------------------------------------------------------------------------------------------------ helpers
private async Task<int> QuarterlyAsync(short type)
{
var meter = await _box.MeterAsync(type, MeterMode.DirectDelta, "kWh", D(2025, 1, 1));
await _box.ReadingsAsync(
meter,
(Midnight(2025, 1, 1), 0), (Midnight(2025, 4, 1), 300), (Midnight(2025, 7, 1), 300), (Midnight(2025, 10, 1), 300), (Midnight(2026, 1, 1), 300));
return meter;
}
private Task<CostAnalysis> BillAsync(short type, ResolvedPeriod period, BucketSize size) =>
_box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), period) { Bucket = size });
private Task<CostAnalysis> CostOfAsync(int meter, ResolvedPeriod period) =>
_box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter), period) { Bucket = BucketSize.Month });
}
@@ -0,0 +1,281 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// Builds what a cost test prices — its own energy types, meters with readings normalized in Berlin, tariffs, manual
/// costs and categories — on a frozen clock of 19 September 2026, 14:37 Berlin, and removes all of it again.
/// </summary>
internal sealed class CostSandbox(TimescaleFixture fx) : IAsyncDisposable
{
public const string BerlinId = "Europe/Berlin";
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId);
/// <summary>The frozen "now" of every request (D-01), after the reference data ends (31 May 2026).</summary>
public static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime();
private readonly List<int> _meters = [];
private readonly List<short> _types = [];
private readonly List<int> _tariffs = [];
private readonly List<int> _manualCosts = [];
private readonly List<int> _categories = [];
public TimescaleFixture Fixture => fx;
public static ResolvedPeriod Custom(DateOnly first, DateOnly last) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, Now, Berlin);
public static ResolvedPeriod Year(int year) => Custom(new DateOnly(year, 1, 1), new DateOnly(year, 12, 31));
public static ResolvedPeriod Month(int year, int month) =>
Custom(new DateOnly(year, month, 1), new DateOnly(year, month, DateTime.DaysInMonth(year, month)));
public static ResolvedPeriod Preset(PeriodPreset preset) => PeriodResolver.Resolve(preset, null, null, Now, Berlin);
public static DateTimeOffset Midnight(int year, int month, int day) => GapAttribution.LocalMidnight(new DateOnly(year, month, day), Berlin);
public static DateOnly D(int year, int month, int day) => new(year, month, day);
public CostReader Reader(string currency = "EUR")
{
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = currency });
return new CostReader(fx, new AnalysisReader(fx, options), options);
}
public async Task<short> TypeAsync(string unit = "kWh")
{
await using var db = fx.CreateContext();
var type = new EnergyType
{
Key = $"cost-{Guid.NewGuid():N}",
DisplayName = "Cost test",
BaseUnit = unit,
DefaultMode = MeterMode.CumulativeCounter,
};
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_types.Add(type.Id);
return type.Id;
}
public async Task<int> MeterAsync(
short type, MeterMode mode, string unit, DateOnly? installedAt = null, string meta = "{}", DateOnly? retiredAt = null, string? name = null)
{
await using var db = fx.CreateContext();
var meter = new Meter
{
Name = name ?? $"cost-{Guid.NewGuid():N}",
EnergyTypeId = type,
Mode = mode,
Unit = unit,
InstalledAt = installedAt,
RetiredAt = retiredAt,
Meta = meta,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
return meter.Id;
}
/// <summary>
/// A counter installed on the 1st of <paramref name="firstMonth"/> whose consecutive local months book the given
/// amounts: one reading at each following local midnight of the 1st.
/// </summary>
public async Task<int> MonthlyAsync(short type, MeterMode mode, DateOnly firstMonth, params double[] months)
{
var meter = await MeterAsync(type, mode, "kWh", installedAt: firstMonth);
await MonthlyReadingsAsync(meter, firstMonth, months);
return meter;
}
/// <summary>Monthly readings on an existing meter, as <see cref="MonthlyAsync"/> writes them.</summary>
public async Task MonthlyReadingsAsync(int meter, DateOnly firstMonth, params double[] months)
{
var register = 0d;
var readings = new List<(DateTimeOffset, double)>();
for (var i = 0; i < months.Length; i++)
{
register += months[i];
var next = firstMonth.AddMonths(i + 1);
readings.Add((Midnight(next.Year, next.Month, 1), register));
}
await ReadingsAsync(meter, [.. readings]);
}
/// <summary>A counter installed on <paramref name="from"/> that rises by <paramref name="perDay"/> at every local midnight up to <paramref name="to"/>.</summary>
public async Task<int> DailyAsync(short type, DateOnly from, DateOnly to, double perDay)
{
var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: from);
var readings = new List<(DateTimeOffset, double)>();
var register = 0d;
for (var day = from.AddDays(1); day <= to; day = day.AddDays(1))
{
register += perDay;
readings.Add((Midnight(day.Year, day.Month, day.Day), register));
}
await ReadingsAsync(meter, [.. readings]);
return meter;
}
public async Task<int> VirtualAsync(short type, string expression, QuantityKind kind, string unit, VirtualCostRule rule, string meta = "{}")
{
meta = VirtualDefinitionJson.Write(meta, new VirtualDefinition(expression, kind, unit, rule));
var meter = await MeterAsync(type, MeterMode.Virtual, unit, meta: meta);
await RecomputeAsync(meter);
return meter;
}
public async Task LinkAsync(int from, int to)
{
await using var db = fx.CreateContext();
db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to });
await db.SaveChangesAsync();
}
public async Task<int> TariffAsync(
TariffScope scope, int? scopeId, TariffComponent component, double value, string unit, DateOnly from, DateOnly? to = null)
{
await using var db = fx.CreateContext();
var tariff = new Tariff
{
ScopeType = scope,
ScopeId = scopeId,
Component = component,
Value = value,
Unit = unit,
ValidFrom = from,
ValidTo = to,
};
db.Tariffs.Add(tariff);
await db.SaveChangesAsync();
_tariffs.Add(tariff.Id);
return tariff.Id;
}
public Task<int> TypePriceAsync(short type, double value, DateOnly from, string unit = "EUR/kWh", DateOnly? to = null) =>
TariffAsync(TariffScope.EnergyType, type, TariffComponent.UnitPrice, value, unit, from, to);
public Task<int> MeterPriceAsync(int meter, double value, DateOnly from, string unit = "EUR/kWh") =>
TariffAsync(TariffScope.Meter, meter, TariffComponent.UnitPrice, value, unit, from);
public async Task<int> ManualCostAsync(DateOnly start, double amount, int? meterId = null, int? categoryId = null, string currency = "EUR")
{
await using var db = fx.CreateContext();
var cost = new ManualCost
{
MeterId = meterId,
CategoryId = categoryId,
PeriodStart = start,
PeriodEnd = start.AddMonths(1).AddDays(-1),
Amount = amount,
Currency = currency,
};
db.ManualCosts.Add(cost);
await db.SaveChangesAsync();
_manualCosts.Add(cost.Id);
return cost.Id;
}
public async Task<int> CategoryAsync(string name, int sort, int[]? meters = null, short[]? types = null)
{
await using var db = fx.CreateContext();
var category = new CostCategory { Name = name, Sort = sort };
foreach (var meter in meters ?? [])
{
category.Members.Add(new CostCategoryMember { MeterId = meter });
}
foreach (var type in types ?? [])
{
category.Members.Add(new CostCategoryMember { EnergyTypeId = type });
}
db.CostCategories.Add(category);
await db.SaveChangesAsync();
_categories.Add(category.Id);
return category.Id;
}
public async Task ReadingsAsync(int meterId, params (DateTimeOffset Time, double Value)[] readings)
{
await using (var db = fx.CreateContext())
{
db.Readings.AddRange(readings.Select(r => new Reading
{
MeterId = meterId,
Time = r.Time.ToUniversalTime(),
Value = r.Value,
Quality = ReadingQuality.Manual,
}));
await db.SaveChangesAsync();
}
await RecomputeAsync(meterId);
}
public async Task RecomputeAsync(params int[] meterIds)
{
await using var db = fx.CreateContext();
await using var tx = await db.Database.BeginTransactionAsync();
foreach (var id in meterIds)
{
await Normalization(db).RecomputeMeterAsync(id, null);
}
await db.SaveChangesAsync();
await tx.CommitAsync();
}
public static NormalizationService Normalization(MeterVaultDbContext db) =>
new(db, NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }),
new FixedTimeProvider(Now));
public async ValueTask DisposeAsync()
{
await using var db = fx.CreateContext();
var ids = _meters.ToArray();
await db.ManualCosts.Where(c => _manualCosts.Contains(c.Id)).ExecuteDeleteAsync();
await db.CostCategories.Where(c => _categories.Contains(c.Id)).ExecuteDeleteAsync();
await db.Tariffs.Where(t => _tariffs.Contains(t.Id)).ExecuteDeleteAsync();
await db.MeterLinks.Where(l => ids.Contains(l.FromMeterId) || ids.Contains(l.ToMeterId)).ExecuteDeleteAsync();
await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync();
await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync();
await db.MeterEvents.Where(e => ids.Contains(e.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();
}
}
/// <summary>Assertions on cost figures.</summary>
internal static class CostAssert
{
public static void Cost(double expected, CostAmount amount, int precision = 6)
{
Assert.NotNull(amount.Cost);
Assert.Equal(expected, amount.Cost!.Value, precision);
}
/// <summary>Priced, with a value, and nothing unavailable in it (not-priced components may have been left out).</summary>
public static void Priced(double expected, CostAmount amount, double tolerance = 1e-6)
{
Assert.Equal(CostStatus.Priced, amount.Status);
Assert.NotNull(amount.Cost);
Assert.InRange(amount.Cost!.Value, expected - tolerance, expected + tolerance);
Assert.DoesNotContain(amount.MissingPrices, m => m.Reason is CostStatus.PriceGap or CostStatus.UnitMismatch);
}
}
@@ -0,0 +1,246 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// The services behind the current pages, rerouted through the analysis reader and the cost engine: the overview's
/// KPIs, breakdown, difference and trend, and the energy page's cost. They keep their
/// signatures and show the corrected bill (D-34 D-42) on the frozen clock of <see cref="CostSandbox.Now"/>.
/// </summary>
/// <remarks>
/// The overview reads the whole instance, so every test starts from — and leaves — an instance
/// without meters, tariffs or manual costs (like <see cref="SeededBillTests"/>).
/// </remarks>
[Collection("Timescale")]
public sealed class DashboardServicesTests(TimescaleFixture fx) : IAsyncLifetime
{
private const int KostenTotal = 2;
private const int KostenStrom = 4;
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task The_seeded_overview_follows_the_bill()
{
// D-44 through the legacy services: "this year" is the sheet's 2026 (the data ends in May), "last year" its 2025,
// the latest month with data is May 2026, and the trend, the breakdown and the energy page all add up to the bill.
await LoadReferenceDataAsync();
var (costs, dashboard) = Services();
var sheet = ReadRows(Costs);
var monthly = OracleByMonth(sheet, dateColumn: 0, valueColumn: KostenTotal, firstDataRow: 1);
var summary = await dashboard.GetSummaryAsync(Now);
Assert.Equal(D(2026, 9, 19), summary.AsOf);
Assert.InRange(summary.Year.Current, 2940.19 - 0.02, 2940.19 + 0.02);
Assert.InRange(summary.Year.Previous, 7907.64 - 0.02, 7907.64 + 0.02);
Assert.True(summary.Year.DeltaPercentApplicable);
Assert.Equal((summary.Year.Current - summary.Year.Previous) / summary.Year.Previous * 100, summary.Year.DeltaPercent, 6);
// September and August 2026 have nothing to price: a zero against a zero, with no percentage.
Assert.Equal((0d, 0d), (summary.Month.Current, summary.Month.Previous));
Assert.False(summary.Month.DeltaPercentApplicable);
Assert.Equal(new LatestMonthWithData(D(2026, 5, 1), LatestPeriodBasis.Both), summary.LatestMonth);
Assert.InRange(summary.LatestMonthCost, monthly[D(2026, 5, 1)] - 0.02, monthly[D(2026, 5, 1)] + 0.02);
// The trend is the bill month by month, manual costs included (A09): each month is the sheet's Kosten.
var trend = await dashboard.GetMonthlyTrendAsync(D(2025, 1, 1), D(2026, 1, 1));
Assert.Equal(12, trend.Count);
AssertReconciles(trend.ToDictionary(p => p.Period, p => p.Cost), monthly, 0.02, "trend", minMatches: 12);
Assert.InRange(trend.Sum(p => p.Cost), 7907.64 - 0.02, 7907.64 + 0.02);
// The breakdown is the bill's composition: Strom (Netz × price), Wasser, Heizung — and nothing else priced.
var breakdown = await dashboard.GetCategoryBreakdownAsync(D(2025, 1, 1), D(2026, 1, 1));
Assert.Equal(["Heizung", "Strom", "Wasser"], breakdown.Select(s => s.Name).Order());
Assert.All(breakdown, s => Assert.Equal(CompositionSliceKind.Category, s.Kind));
Assert.InRange(breakdown.Sum(s => s.Cost), 7907.64 - 0.02, 7907.64 + 0.02);
// The energy page's cost is the type's bill, not every electricity meter summed (A04).
await using var db = fx.CreateContext();
var electricity = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
var strom = await costs.GetEnergyTypeCostAsync(electricity, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
var stromSheet = OracleByMonth(sheet, 0, KostenStrom, 1).Where(m => m.Key.Year == 2025).Sum(m => m.Value);
Assert.Equal(CostStatus.Priced, strom.Status);
Assert.InRange(strom.Cost!.Value, stromSheet - 0.02, stromSheet + 0.02);
Assert.Equal(breakdown.Single(s => s.Name == "Strom").Cost, strom.Cost!.Value, 6);
}
[Fact]
public async Task The_breakdown_is_the_bill_s_composition_and_the_trend_adds_up_to_it()
{
await using var box = new CostSandbox(fx);
var (t1, t2) = (await box.TypeAsync(), await box.TypeAsync());
var a = await box.MonthlyAsync(t1, MeterMode.CumulativeCounter, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 20)]);
await box.MonthlyAsync(t2, MeterMode.CumulativeCounter, D(2025, 1, 1), [.. Enumerable.Repeat(50d, 20)]);
await box.TypePriceAsync(t1, 0.10, D(2025, 1, 1));
await box.TypePriceAsync(t2, 0.20, D(2025, 1, 1));
await box.TariffAsync(TariffScope.Global, null, TariffComponent.BasePrice, 5, "EUR/month", D(2025, 1, 1));
var category = await box.CategoryAsync($"A {Guid.NewGuid():N}", 100, meters: [a]);
await box.ManualCostAsync(D(2026, 3, 10), 7);
var (_, dashboard) = Services();
// This year to now (19 September): a and b for January to August (their data ends on 1 September), the global
// standing charge for every day up to today, and the manual cost once.
var standing = (8 * 5) + (19 * 5 / 30d);
var breakdown = await dashboard.GetCategoryBreakdownAsync(D(2026, 1, 1), D(2026, 10, 19));
Assert.Equal(
[(CompositionSliceKind.Uncategorized, (int?)null, 80 + 7d), (CompositionSliceKind.Category, category, 80d), (CompositionSliceKind.StandingCharge, null, Math.Round(standing, 6))],
breakdown.Select(s => (s.Kind, s.CategoryId, Math.Round(s.Cost, 6))));
Assert.Equal(TariffScope.Global, breakdown[2].StandingCharge!.Scope);
Assert.Equal(string.Empty, breakdown[0].Name);
var summary = await dashboard.GetSummaryAsync(Now);
Assert.Equal(summary.Year.Current, breakdown.Sum(s => s.Cost), 6);
Assert.Equal(12 * 25d, summary.Year.Previous, 6);
// The trend: 2024 lies before both meters' install dates, so the bill of each of its months is a known zero (D-24)
// — a point at 0, not a gap. The months of this year add up to the year.
var trend = await dashboard.GetMonthlyTrendAsync(D(2024, 1, 1), D(2026, 10, 19));
Assert.Equal(D(2024, 1, 1), trend[0].Period);
Assert.Equal(33, trend.Count);
Assert.All(trend.Where(p => p.Period.Year == 2024), p => Assert.Equal(0, p.Cost));
Assert.Equal(25, trend.Single(p => p.Period == D(2025, 1, 1)).Cost, 6);
Assert.Equal(32, trend.Single(p => p.Period == D(2026, 3, 1)).Cost, 6);
Assert.Equal(19 * 5 / 30d, trend[^1].Cost, 6);
Assert.Equal(summary.Year.Current, trend.Where(p => p.Period.Year == 2026).Sum(p => p.Cost), 6);
}
[Fact]
public async Task A_month_nothing_was_measured_in_is_not_a_point_on_the_trend()
{
// A meter without an install date is unknown before its first reading, not zero; its type's standing charge only
// starts with its service. Those months are no points — not the known 0 the engine gives a charge not yet due.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await box.ReadingsAsync(
meter, (new DateTimeOffset(2026, 2, 1, 8, 0, 0, TimeSpan.FromHours(1)), 0), (Midnight(2026, 3, 1), 100), (Midnight(2026, 4, 1), 250));
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
await box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 5, "EUR/month", D(2025, 1, 1));
var (_, dashboard) = Services();
var trend = await dashboard.GetMonthlyTrendAsync(D(2025, 6, 1), D(2026, 5, 1));
// February: 100 kWh and the charge from the first data day; March: 150 kWh and the charge; April: the charge alone
// (the service runs on through a reading gap, D-40).
Assert.Equal(
[(D(2026, 2, 1), 15d), (D(2026, 3, 1), 20d), (D(2026, 4, 1), 5d)],
trend.Select(p => (p.Period, Math.Round(p.Cost, 6))));
}
[Fact]
public async Task The_difference_view_compares_the_same_elapsed_part_of_last_year()
{
// 10 kWh a day in 2025, 12 in 2026. This year to 19 September 14:37 is set against 1 January 19 September 14:37
// of 2025 (D-06), not against whole months or the whole year: 261 days each.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2025, 1, 1));
var readings = new List<(DateTimeOffset, double)>();
var register = 0d;
for (var day = D(2025, 1, 1); day < D(2026, 9, 19); day = day.AddDays(1))
{
register += day.Year == 2025 ? 10 : 12;
var next = day.AddDays(1);
readings.Add((Midnight(next.Year, next.Month, next.Day), register));
}
await box.ReadingsAsync(meter, [.. readings]);
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
var category = await box.CategoryAsync($"D {Guid.NewGuid():N}", 100, meters: [meter]);
var (_, dashboard) = Services();
var rows = await dashboard.GetCategoryDifferenceAsync(D(2026, 1, 1), D(2025, 1, 1), D(2026, 10, 19));
var row = Assert.Single(rows);
Assert.Equal(category, row.CategoryId);
Assert.Equal(261 * 12 * 0.10, row.Current, 6);
Assert.Equal(261 * 10 * 0.10, row.Previous, 6);
Assert.True(row.DeltaPercentApplicable);
Assert.Equal(20, row.DeltaPercent, 6);
}
[Fact]
public async Task An_energy_type_costs_its_billed_meters_and_a_meter_its_own_rule()
{
// A04: the energy page summed every meter of the type — the grid, the house behind it and a subsection of the
// house. The type's bill is the grid import (D-34); each meter's own cost stays available as a view.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
var car = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), [.. Enumerable.Repeat(50d, 12)]);
var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2025, 1, 1), [.. Enumerable.Repeat(250d, 12)]);
await box.MonthlyReadingsAsync(grid, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 12)]);
await box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(300d, 12)]);
await box.LinkAsync(house, car);
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
var (costs, _) = Services();
var bill = await costs.GetEnergyTypeCostAsync(type, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
Assert.Equal(1200 * 0.10, bill.Cost!.Value, 6);
var houseCosts = await costs.GetMeterCostsAsync(house, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
Assert.Equal(12, houseCosts.Count);
Assert.All(houseCosts, c => Assert.Equal((300d, 30d), (c.Consumption, Math.Round(c.Cost, 6))));
// A generation meter reports generation, and is not costed.
var generation = await costs.GetMeterCostsAsync(pv, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
Assert.All(generation, c => Assert.Equal((0d, 250d, 0d, QuantityKind.Generation), (c.Consumption, c.Generation, c.Cost, c.Kind)));
}
private (CostService Costs, DashboardService Dashboard) Services()
{
var clock = new FixedTimeProvider(Now);
var costs = new CostService(fx, Options(), clock);
return (costs, new DashboardService(fx, costs, clock));
}
private static Microsoft.Extensions.Options.IOptions<MeterVaultOptions> Options() =>
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
private async Task LoadReferenceDataAsync()
{
await using var db = fx.CreateContext();
var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter());
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
}
@@ -0,0 +1,341 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Xunit.Abstractions;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// The whole bill (D-34 D-44): the seeded reference instance against the Kosten sheet's <c>Jahreskosten</c>, the
/// category composition that reconciles with it, standing charges once per scope, and an instance with nothing but
/// manual costs. The portfolio is everything in the database, so every test starts from — and leaves — an instance
/// without meters, tariffs or manual costs (like <see cref="DashboardRenderTests"/>).
/// </summary>
[Collection("Timescale")]
public sealed class SeededBillTests(TimescaleFixture fx, ITestOutputHelper output) : IAsyncLifetime
{
private const int KostenHeizung = 3;
private const int KostenStrom = 4;
private const int KostenWasser = 5;
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task The_seeded_yearly_bill_equals_the_sheet_s_Jahreskosten()
{
// D-44: with the tank unpriced and on a clock after the data ends (31 May 2026), the seeded bill is the sheet's
// yearly cost within 2 cents: Strom = Netz × price, water metered, Heizung from the imported manual costs.
await LoadReferenceDataAsync();
var reader = new CostSandbox(fx).Reader();
var sheet = ReadRows(Costs);
await using var db = fx.CreateContext();
var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Name, m => m.Id);
var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => t.Key, t => (int)t.Id);
var categories = await db.CostCategories.AsNoTracking().ToDictionaryAsync(c => c.Name, c => c.Id);
foreach (var (year, jahreskosten) in new[] { (2022, 421.52), (2025, 7907.64), (2026, 2940.19) })
{
var bill = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(year)) { Bucket = BucketSize.Month, IncludeCategories = true });
output.WriteLine(FormattableString.Invariant($"{year}: bill {bill.Total.Cost:F4} (sheet {jahreskosten:F2}), {bill.Total.Status}"));
// The yearly bill, with nothing unavailable in it: the tank is "not priced", an attention item only.
Assert.Equal(CostStatus.Priced, bill.Total.Status);
Assert.InRange(bill.Total.Cost!.Value, jahreskosten - 0.02, jahreskosten + 0.02);
Assert.All(bill.MissingPrices, m => Assert.Equal((CostStatus.NotPriced, (int?)meters["Öltank"]), (m.Reason, m.MeterId)));
Assert.Equal(bill.Total.Cost!.Value, bill.Buckets.Sum(b => b.Cost ?? 0), 6);
// Strom is the grid import alone, priced month by month: the sheet's Kosten = Netz × €/kWh.
var strom = bill.EnergyTypes.Single(t => t.EnergyTypeId == types["electricity"]);
Assert.Equal([meters["Zähler Netz"]], strom.LineMeterIds);
var netz = bill.Lines.Single(l => l.MeterId == meters["Zähler Netz"]);
for (var b = 0; b < bill.Buckets.Count; b++)
{
var month = bill.Plan.Buckets[b].FirstDay;
if (netz.Quantities[b] is { } kWh && netz.Buckets[b].Cost is { } cost)
{
Assert.Equal(kWh * StromPrice(month), cost, 6);
}
}
Assert.InRange(strom.Total.Cost!.Value - SheetSum(sheet, KostenStrom, year), -0.02, 0.02);
var wasser = bill.EnergyTypes.Single(t => t.EnergyTypeId == types["water"]);
Assert.InRange(wasser.Total.Cost!.Value - SheetSum(sheet, KostenWasser, year), -0.02, 0.02);
// Heizung comes from manual costs, each booked once.
var heizung = SheetSum(sheet, KostenHeizung, year);
Assert.InRange((bill.ManualCosts.Total.Cost ?? 0) - heizung, -0.02, 0.02);
Assert.Equal(bill.ManualCosts.Bookings.Count, bill.ManualCosts.Bookings.Select(m => m.ManualCostId).Distinct().Count());
Assert.All(bill.ManualCosts.Bookings, m => Assert.Equal(categories["Heizung"], m.CategoryId));
// The composition — disjoint categories, Uncategorized, standing charges — is the bill (D-42).
var composition = bill.Composition!;
Assert.Equal(bill.Total.Cost!.Value, composition.Total.Cost!.Value, 6);
for (var b = 0; b < bill.Buckets.Count; b++)
{
Assert.Equal(bill.Buckets[b].Cost ?? 0, composition.Buckets[b].Cost ?? 0, 6);
}
Assert.Equal(strom.Total.Cost, Slice(composition, categories["Strom"]).Total.Cost);
Assert.Equal(wasser.Total.Cost, Slice(composition, categories["Wasser"]).Total.Cost);
Assert.Equal(bill.ManualCosts.Total.Cost, Slice(composition, categories["Heizung"]).Total.Cost);
var uncategorized = composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized);
Assert.Equal([meters["Öltank"]], uncategorized.MeterIds);
Assert.Null(uncategorized.Total.Cost);
Assert.All(composition.Categories, c => Assert.False(c.IsOverlappingView));
Assert.True(composition.DonutAllowed);
}
// Every seeded meter names how its own cost is formed (D-34, D-39).
var rules = new Dictionary<string, (MeterCostRule Rule, bool OnBill)>
{
["Zähler Netz"] = (MeterCostRule.BillLine, true),
["Zähler Haus"] = (MeterCostRule.UnitPriceView, false),
["Zähler Auto"] = (MeterCostRule.UnitPriceView, false),
["Zähler Solar 1"] = (MeterCostRule.None, false),
["Brenner"] = (MeterCostRule.None, false),
["Öltank"] = (MeterCostRule.BillLine, true),
["Summe Solar"] = (MeterCostRule.None, false),
};
foreach (var (name, expected) in rules)
{
var own = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters[name]), Year(2025)) { Bucket = BucketSize.Month });
Assert.Equal(expected, (own.Meter!.Rule, own.Meter.OnBill));
}
// Summe Solar is generation: never a purchase cost (review R1, A-15) — not 4,750 kWh at 0.36 €.
var summe = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters["Summe Solar"]), Year(2025)) { Bucket = BucketSize.Month });
Assert.Null(summe.Total.Cost);
Assert.Empty(summe.Lines);
Assert.Equal(MeterNotCostedReason.Generation, summe.Meter!.NotCosted);
var haus = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters["Zähler Haus"]), Year(2025)) { Bucket = BucketSize.Month });
Assert.Equal(haus.Lines.Single().TotalQuantity!.Value * 0.36, haus.Total.Cost!.Value, 6);
var tank = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meters["Öltank"]), Year(2025)) { Bucket = BucketSize.Month });
Assert.Equal(CostStatus.NotPriced, tank.Total.Status);
// Water, December 2022: 14 m³ × 5,00 € (D-56), through the Wasser category.
var december = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(categories["Wasser"]), Month(2022, 12)) { Bucket = BucketSize.Month });
CostAssert.Priced(70.00, december.Total, 0.005);
Assert.Equal([meters["Zähler Wasser"]], december.Category!.Cover.BilledMeterIds);
// 2023 and 2024 differ from the sheet by 3.78 € and 0.46 € (D-44): the sheet multiplies by unrounded prices it
// displays rounded (e.g. May 2023, 414,33 € for a 0,37 €/kWh month). Documented, not tuned away.
foreach (var (year, jahreskosten, difference) in new[] { (2023, 7904.46, 3.78), (2024, 6783.05, 0.46) })
{
var bill = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(year)) { Bucket = BucketSize.Month });
output.WriteLine(FormattableString.Invariant($"{year}: bill {bill.Total.Cost:F4} (sheet {jahreskosten:F2})"));
Assert.Equal(CostStatus.Priced, bill.Total.Status);
Assert.InRange(Math.Abs(bill.Total.Cost!.Value - jahreskosten), difference - 0.02, difference + 0.02);
}
// The bucket size never changes the year: months, weeks, the year as one bucket.
var watch = System.Diagnostics.Stopwatch.StartNew();
var monthly = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(2025)) { Bucket = BucketSize.Month });
output.WriteLine(FormattableString.Invariant($"portfolio 2025 by month: {watch.ElapsedMilliseconds} ms"));
foreach (var size in new[] { BucketSize.Year, BucketSize.Week })
{
var other = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Year(2025)) { Bucket = size });
Assert.Equal(monthly.Total.Cost!.Value, other.Total.Cost!.Value, 6);
Assert.Equal(CostStatus.Priced, other.Total.Status);
}
// Auto charts the bill by the resolution of what is priced: the monthly sheets, not the unpriced tank.
var auto = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last24Months)));
Assert.Equal(BucketSize.Month, auto.Plan.Size);
Assert.Equal(24, auto.Buckets.Count);
// The latest period with data is May 2026, from meters and manual costs alike (D-19).
var latest = await reader.GetAvailabilityAsync(CostScope.Portfolio, Now);
Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Both), latest.Latest);
}
[Fact]
public async Task Standing_charges_and_categories_compose_the_portfolio_bill()
{
await using var box = new CostSandbox(fx);
var (t1, t2, t3) = (await box.TypeAsync(), await box.TypeAsync(), await box.TypeAsync());
var a = await box.MonthlyAsync(t1, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
var b = await box.MonthlyAsync(t2, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 100, 100);
var grid = await box.MeterAsync(t3, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var export = await box.MeterAsync(t3, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport));
await box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 100, 100, 100);
await box.MonthlyReadingsAsync(export, D(2026, 1, 1), 1000, 1000, 1000);
foreach (var type in new[] { t1, t2, t3 })
{
await box.TypePriceAsync(type, 0.10, D(2026, 1, 1));
}
await box.TariffAsync(TariffScope.EnergyType, t3, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2026, 1, 1));
await box.TariffAsync(TariffScope.EnergyType, t1, TariffComponent.BasePrice, 3, "EUR/month", D(2026, 1, 1));
await box.TariffAsync(TariffScope.Global, null, TariffComponent.BasePrice, 10, "EUR/month", D(2026, 1, 1));
var viewA = await box.CategoryAsync($"A {Guid.NewGuid():N}", 100, meters: [a]);
var viewA2 = await box.CategoryAsync($"A2 {Guid.NewGuid():N}", 101, meters: [a]);
var typeB = await box.CategoryAsync($"B {Guid.NewGuid():N}", 102, types: [t2]);
var credit = await box.CategoryAsync($"X {Guid.NewGuid():N}", 103, meters: [export]);
var onMeter = await box.ManualCostAsync(D(2026, 2, 1), 25, meterId: b);
var onView = await box.ManualCostAsync(D(2026, 2, 1), 7, categoryId: viewA);
// Two categories that price nothing but share a manual cost (on a generator, never billed) cannot both be
// slices: the cost would be added twice.
var t4 = await box.TypeAsync();
var pv = await box.MonthlyAsync(t4, MeterMode.GenerationCounter, D(2026, 1, 1), 50, 50, 50);
var sharedG1 = await box.CategoryAsync($"G1 {Guid.NewGuid():N}", 104, meters: [pv]);
var sharedG2 = await box.CategoryAsync($"G2 {Guid.NewGuid():N}", 105, meters: [pv]);
var onPv = await box.ManualCostAsync(D(2026, 2, 1), 11, meterId: pv);
var quarter = Custom(D(2026, 1, 1), D(2026, 3, 31));
var bill = await box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, quarter) { Bucket = BucketSize.Month, IncludeCategories = true });
// Lines: a, b, the grid at 0.10 on 300 kWh each; the export credit 3000 × 0.08. Rows: the type's 3 × 3 €, the
// global 3 × 10 € — each once, however many meters are in service (D-40). Manual costs once each (D-41): 25 €
// on b goes with b's type, 7 € on a category with the portfolio.
Assert.Equal(
[(TariffScope.EnergyType, (int?)t1, 9d), (TariffScope.Global, (int?)null, 30d)],
bill.StandingCharges.Select(r => (r.Scope, r.ScopeId, Math.Round(r.Total.Cost!.Value, 6))));
CostAssert.Priced(90 - 240 + 9 + 30 + 25 + 7 + 11, bill.Total);
Assert.Equal([onMeter, onView, onPv], bill.ManualCosts.Bookings.Select(m => m.ManualCostId).Order());
Assert.Equal(D(2026, 1, 1), bill.StandingCharges[1].Service!.FirstDay);
Assert.Equal([39d, 55d, -210d, 11d], bill.EnergyTypes.Select(t => Math.Round(t.Total.Cost!.Value, 6)));
// A type's bill carries its own standing charge, never the global one.
var typeBill = await box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(t1), quarter) { Bucket = BucketSize.Month });
Assert.Equal((TariffScope.EnergyType, (int?)t1), (Assert.Single(typeBill.StandingCharges).Scope, typeBill.StandingCharges[0].ScopeId));
CostAssert.Priced(39, typeBill.Total);
// The composition: B (with b's manual cost) and X are slices, A and A2 share a's line (and t1's charge) and are
// views; a's line and A's manual cost go to Uncategorized with the grid, and the two charges no slice holds are
// rows of their own.
var composition = bill.Composition!;
Assert.Equal(bill.Total.Cost!.Value, composition.Total.Cost!.Value, 6);
Assert.Equal(30 + 25, Slice(composition, typeB).Total.Cost!.Value, 6);
Assert.Equal([onMeter], Slice(composition, typeB).ManualCostIds);
Assert.Equal(-240, Slice(composition, credit).Total.Cost!.Value, 6);
var uncategorized = composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized);
Assert.Equal([a, grid], uncategorized.MeterIds);
Assert.Equal([onView, onPv], uncategorized.ManualCostIds);
Assert.Equal(60 + 7 + 11, uncategorized.Total.Cost!.Value, 6);
Assert.All(composition.Categories.Where(c => c.CategoryId == sharedG1 || c.CategoryId == sharedG2), c =>
{
Assert.True(c.IsOverlappingView);
Assert.Equal(11, c.Total.Cost!.Value, 6);
});
Assert.Equal([onPv], composition.Overlaps.Single(o => o.CategoryId == sharedG1 && o.OtherCategoryId == sharedG2).SharedManualCostIds);
Assert.Equal(
[(TariffScope.EnergyType, (int?)t1), (TariffScope.Global, (int?)null)],
composition.Slices.Where(s => s.Kind == CompositionSliceKind.StandingCharge).Select(s => (s.StandingCharge!.Scope, s.StandingCharge.ScopeId)));
Assert.DoesNotContain(composition.Slices, s => s.CategoryId == viewA || s.CategoryId == viewA2);
var figureA = composition.Categories.Single(c => c.CategoryId == viewA);
Assert.True(figureA.IsOverlappingView);
Assert.Equal([viewA2], figureA.OverlapsWith);
Assert.Equal(30 + 9 + 7, figureA.Total.Cost!.Value, 6);
var overlap = Assert.Single(composition.Overlaps, o => o.CategoryId == Math.Min(viewA, viewA2) && o.OtherCategoryId == Math.Max(viewA, viewA2));
Assert.Equal([a], overlap.SharedMeterIds);
Assert.Equal([new StandingChargeKey(TariffScope.EnergyType, t1)], overlap.SharedStandingCharges);
Assert.False(composition.Categories.Single(c => c.CategoryId == typeB).IsOverlappingView);
// A credit larger than its charges is a negative slice: signed bars, not a donut (D-42).
Assert.False(composition.DonutAllowed);
// The category on its own reads the same figure as in the composition.
var alone = await box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(viewA), quarter) { Bucket = BucketSize.Month });
Assert.True(alone.Category!.IsOverlappingView);
Assert.Equal(figureA.Total.Cost, alone.Total.Cost);
}
[Fact]
public async Task A_manual_cost_only_instance_agrees_across_overview_trend_categories_and_latest_month()
{
// Brief §11: no meter at all, only manual costs — overview, trend, category breakdown and the latest month agree.
await using var box = new CostSandbox(fx);
var category = await box.CategoryAsync($"Manual {Guid.NewGuid():N}", 100);
var march = await box.ManualCostAsync(D(2026, 3, 5), 100, categoryId: category);
var loose = await box.ManualCostAsync(D(2026, 3, 20), 40);
var may = await box.ManualCostAsync(D(2026, 5, 10), 60, categoryId: category);
var reader = box.Reader();
var latest = await reader.GetAvailabilityAsync(CostScope.Portfolio, Now);
Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Manual), latest.Latest);
var overview = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Month(2026, 5)));
CostAssert.Priced(60, overview.Total);
Assert.Equal(latest.Latest, overview.Availability.Latest);
Assert.Empty(overview.Lines);
var trend = await reader.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, Preset(PeriodPreset.Last12Months)) { Bucket = BucketSize.Month, IncludeCategories = true });
Assert.Equal(12, trend.Buckets.Count);
var byMonth = trend.Plan.Buckets.Select((b, i) => (b.FirstDay, trend.Buckets[i].Cost)).ToDictionary(x => x.FirstDay, x => x.Cost);
Assert.Equal(140, byMonth[D(2026, 3, 1)]);
Assert.Equal(overview.Total.Cost, byMonth[D(2026, 5, 1)]);
Assert.Null(byMonth[D(2026, 4, 1)]);
CostAssert.Priced(200, trend.Total);
var composition = trend.Composition!;
Assert.Equal(160, Slice(composition, category).Total.Cost);
Assert.Equal([march, may], Slice(composition, category).ManualCostIds);
var uncategorized = composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized);
Assert.Equal([loose], uncategorized.ManualCostIds);
Assert.Equal(40, uncategorized.Total.Cost);
Assert.Equal(200, composition.Total.Cost);
var alone = await reader.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(category), Preset(PeriodPreset.Last12Months)) { Bucket = BucketSize.Month });
CostAssert.Priced(160, alone.Total);
}
private async Task LoadReferenceDataAsync()
{
await using var db = fx.CreateContext();
var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter());
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
/// <summary>The seeded electricity price on the 15th of a month (ReferenceDataImporter).</summary>
private static double StromPrice(DateOnly month) => month switch
{
_ when month >= D(2026, 1, 1) => 0.27,
_ when month >= D(2025, 1, 1) => 0.36,
_ when month >= D(2023, 11, 1) => 0.27,
_ when month >= D(2023, 5, 1) => 0.37,
_ when month >= D(2023, 1, 1) => 0.44,
_ => 0.16,
};
private static double SheetSum(IReadOnlyList<string[]> rows, int column, int year) =>
OracleByMonth(rows, dateColumn: 0, valueColumn: column, firstDataRow: 1).Where(m => m.Key.Year == year).Sum(m => m.Value);
private static CompositionSlice Slice(CategoryComposition composition, int categoryId) =>
composition.Slices.Single(s => s.Kind == CompositionSliceKind.Category && s.CategoryId == categoryId);
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
}
+266 -26
View File
@@ -38,12 +38,38 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
// Regression (audit): Wasser is metered (water tariff), the Kosten Wasser column is
// NOT imported, so the category is not double-counted — Dez 2022 = 14 m³ × 5 € = 70 €.
// The app's own service: it reads in the zone the import normalized in (a bare
// `new CostService(fx)` reads UTC, the normalizer's default without options).
var wasser = await db.CostCategories.FirstAsync(c => c.Name == "Wasser");
var rollup = await new CostService(fx).GetCategoryCostsAsync(
using var costScope = factory.Services.CreateScope();
var rollup = await costScope.ServiceProvider.GetRequiredService<CostService>().GetCategoryCostsAsync(
wasser.Id,
new DateTimeOffset(2022, 12, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero));
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
// Summe Solar is seeded with its calculation written down (D-28): Solar 1 + Solar 2, generation in kWh,
// not costed (generation is never billed, A-15). Its links stay flow topology.
var byName = await db.Meters.ToDictionaryAsync(m => m.Name);
var summe = MeterVault.Core.Analysis.Virtual.VirtualDefinitionJson.Read(byName["Summe Solar"].Meta);
Assert.Equal(MeterVault.Core.Analysis.Virtual.VirtualDefinitionReadStatus.Present, summe.Status);
Assert.Equal(
new[] { byName["Zähler Solar 1"].Id, byName["Zähler Solar 2"].Id }.Order(),
summe.Definition!.ReferencedMeterIds);
Assert.True(summe.Definition.Formula!.IsPureSum);
Assert.Equal(MeterVault.Core.Analysis.QuantityKind.Generation, summe.Definition.ResultKind);
Assert.Equal("kWh", summe.Definition.ResultUnit);
Assert.Equal(MeterVault.Core.Analysis.Virtual.VirtualCostRule.None, summe.Definition.CostRule);
}
// The startup conversion has nothing to do for the seeded Summe Solar (D-28).
using (var scope = factory.Services.CreateScope())
{
await using var db = fx.CreateContext();
var summeSolarId = await db.Meters.Where(m => m.Name == "Summe Solar").Select(m => m.Id).FirstAsync();
var upgrade = await scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Analysis.VirtualDefinitionUpgrade>().RunAsync();
Assert.DoesNotContain(summeSolarId, upgrade.Converted);
Assert.DoesNotContain(upgrade.NeedsConfiguration, u => u.MeterId == summeSolarId);
}
// Panel read models compute real figures from the reference data (SDD §8.4–§8.6).
@@ -55,26 +81,37 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var wide = new DateOnly(1997, 1, 1);
var toEnd = new DateOnly(2027, 1, 1);
var solar = await services.GetRequiredService<SolarService>().GetSummaryAsync(wide, toEnd);
Assert.True(solar.HasGeneration);
Assert.True(solar.Generation > 0);
// Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve.
Assert.True(solar.HasLoadContext);
Assert.NotNull(solar.SelfConsumption);
Assert.NotNull(solar.Savings);
// The specialized views read the whole sheet (1997 2026) through the shared readers, in the app's zone.
var solarService = services.GetRequiredService<SolarService>();
var sheetYears = MeterVault.Core.Analysis.PeriodResolver.Resolve(
MeterVault.Core.Analysis.PeriodPreset.Custom, wide, toEnd.AddDays(-1), DateTimeOffset.UtcNow, solarService.Zone);
var solar = Assert.Single((await solarService.GetAsync(new SolarRequest(sheetYears))).Sites);
Assert.True(solar.Generation!.Total.Value > 0);
// Haus (total consumption) and Netz (grid import) hold their roles, so self-consumption and savings resolve;
// nobody exports, which the view names as a role to set up rather than a zero feed-in.
Assert.True(solar.SelfConsumption!.Total.Value > 0);
Assert.NotNull(solar.Savings!.Total.Cost);
Assert.False(solar.RoleOf(MeterVault.Core.Analysis.Quantities.MeterRole.GridExport).IsSet);
var consumables = await services.GetRequiredService<ConsumableService>().GetConsumablesAsync(wide, toEnd);
var oil = Assert.Single(consumables);
Assert.True(oil.CurrentLevel is > 0);
var consumables = await services.GetRequiredService<ConsumableService>().GetAsync(new ConsumableRequest(sheetYears));
var oil = Assert.Single(consumables.Tanks);
Assert.True(oil.EstimatedNow?.Volume > 0);
Assert.NotNull(oil.LastDipstick);
Assert.NotEmpty(oil.Deliveries);
Assert.True(oil.ConsumptionInRange > 0);
Assert.True(oil.Usage!.Total.Value > 0);
await using var db = fx.CreateContext();
hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync();
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
// The meter page's identity read carries no figures any more (they come from the analysis reader); its
// record tabs page through the rows (D-50).
var details = services.GetRequiredService<MeterDetailService>();
var detail = await details.GetAsync(hausId);
Assert.NotNull(detail);
Assert.True(detail!.ReadingCount > 0);
Assert.True(detail.TotalConsumption > 0);
Assert.True(detail!.HasReadings);
Assert.NotNull(detail.LastReading);
Assert.Equal("kWh", detail.NormalizedUnit);
Assert.True((await details.GetReadingsAsync(hausId, RecordRange.All)).Total > 0);
Assert.True((await details.GetConsumptionAsync(hausId, RecordRange.All)).Total > 0);
// Flow graph: the demo Haus → Auto chain yields a link + an "Other (Haus)" remainder.
electricityTypeId = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
@@ -82,6 +119,16 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
.GetFlowAsync(electricityTypeId, new DateOnly(1997, 1, 1), new DateOnly(2027, 1, 1));
Assert.True(flow.HasChain);
Assert.Contains(flow.Nodes, n => n.IsOther);
// Summe Solar is drawn from its formula: its incoming edges are its two sources, marked calculated, and
// they add up to its own value (D-30).
var summeId = await db.Meters.Where(m => m.Name == "Summe Solar").Select(m => m.Id).FirstAsync();
var intoSumme = flow.Links.Where(l => l.To == $"m{summeId}").ToList();
Assert.Equal(2, intoSumme.Count);
Assert.All(intoSumme, l => Assert.True(l.IsCalculated));
Assert.Equal(flow.Nodes.Single(n => n.MeterId == summeId).Value, intoSumme.Sum(l => l.Value), 6);
Assert.Equal(MeterVault.Infrastructure.Analysis.SeriesBasis.Virtual, flow.MeterFor(summeId)!.Basis);
Assert.Equal(await db.Meters.CountAsync(m => m.EnergyTypeId == electricityTypeId), flow.Meters.Count);
}
using var client = factory.CreateClient();
@@ -90,11 +137,60 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
overview.EnsureSuccessStatusCode();
var html = await overview.Content.ReadAsStringAsync();
Assert.Contains("Overview", html, StringComparison.Ordinal);
// These labels live only in the rendered-KPI-card branch, so their presence proves the
// summary loaded and the cards rendered (non-ASCII like € is HTML-entity-encoded).
Assert.Contains("This month", html, StringComparison.Ordinal);
Assert.Contains("This year", html, StringComparison.Ordinal);
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
// The coverage summary names the latest month with data and what it rests on (D-19): the reference data ends
// in May 2026, with meter data and manual costs alike. It is there whatever the clock says.
Assert.Contains("Latest month with data: May 2026 (Meter data and manual costs)", html, StringComparison.Ordinal);
// A fixed range renders the loaded panels (the default month to date depends on the clock; the frozen-clock
// Overview tests cover it): the bill of 2025 is the sheet's, with the change table and the composition.
var year = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri("/?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Total cost", year, StringComparison.Ordinal);
Assert.Matches(@"7,907\.6[3-6] €", year);
Assert.Contains("Total (the bill)", year, StringComparison.Ordinal);
Assert.Contains("Cost composition", year, StringComparison.Ordinal);
// The navigation (D-48): Analysis, the per-type analysis group, the specialized views, data import and the
// configuration group. The reference data has generation counters and a tank, so no setup hint shows.
Assert.Contains("Analysis", html, StringComparison.Ordinal);
Assert.Contains("Specialized views", html, StringComparison.Ordinal);
Assert.Contains("Tanks &amp; consumables", html, StringComparison.Ordinal);
Assert.Contains("Data import", html, StringComparison.Ordinal);
Assert.Contains("Configuration", html, StringComparison.Ordinal);
Assert.Contains($"href=\"/energy/{electricityTypeId}\"", html, StringComparison.Ordinal);
Assert.DoesNotContain("No generation meter yet", html, StringComparison.Ordinal);
Assert.DoesNotContain("No tank set up yet", html, StringComparison.Ordinal);
// Dark unless the theme cookie says otherwise, and the toggle names what it does (D-49).
Assert.Contains("aria-label=\"Light mode\"", html, StringComparison.Ordinal);
using (var lightClient = factory.CreateClient())
{
lightClient.DefaultRequestHeaders.Add("Cookie", "mv-theme=light");
var light = await lightClient.GetStringAsync(new Uri("/", UriKind.Relative));
Assert.Contains("aria-label=\"Dark mode\"", light, StringComparison.Ordinal);
}
// Expanded navigation groups come from their cookie, and the group of the page shown is open whatever it
// says (D-48): on /solar with only Configuration remembered, Specialized views opens too.
using (var navClient = factory.CreateClient())
{
navClient.DefaultRequestHeaders.Add("Cookie", "mv-nav=config");
var solarPage = await navClient.GetStringAsync(new Uri("/solar", UriKind.Relative));
Assert.Equal("false", GroupExpanded(solarPage, "Energy types"));
Assert.Equal("true", GroupExpanded(solarPage, "Specialized views"));
Assert.Equal("true", GroupExpanded(solarPage, "Configuration"));
}
Assert.Equal("true", GroupExpanded(html, "Energy types"));
Assert.Equal("false", GroupExpanded(html, "Configuration"));
// The preference helper ships; the ApexCharts bundles 6.x no longer has are not referenced.
var script = System.Text.RegularExpressions.Regex.Match(html, "<script src=\"(metervault[^\"]*[.]js)\"").Groups[1].Value;
Assert.NotEmpty(script);
Assert.Contains("setPreference", await client.GetStringAsync(new Uri("/" + script, UriKind.Relative)), StringComparison.Ordinal);
Assert.DoesNotContain("apex-charts.min.js", html, StringComparison.Ordinal);
// The configuration page for energy types says it edits definitions, not the analysis.
var definitions = await client.GetStringAsync(new Uri("/admin/energy-types", UriKind.Relative));
Assert.Contains("Energy type definitions", definitions, StringComparison.Ordinal);
foreach (var path in new[]
{
@@ -108,6 +204,36 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
response.EnsureSuccessStatusCode();
}
// The energy type page (brief §7.3): titled with the type's own name, one card per measure — never the old
// "flow" title or a top-level throughput that added supply to use — the bill named by its basis, and the
// Overview | History | Flow | Meters tabs; the Flow tab manages connections and has its table equivalent.
// A fixed year of the reference data, so the cards render whatever today is.
var energyPage = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
new Uri($"/energy/{electricityTypeId}?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains(">Strom</h1>", energyPage, StringComparison.Ordinal);
Assert.Contains("Total use", energyPage, StringComparison.Ordinal);
Assert.Contains("Grid import", energyPage, StringComparison.Ordinal);
Assert.Contains("Counted: Zähler Solar 1, Zähler Solar 2", energyPage, StringComparison.Ordinal);
Assert.Contains("Billed by grid import (Zähler Netz)", energyPage, StringComparison.Ordinal);
Assert.Contains("Meters (6)", energyPage, StringComparison.Ordinal);
Assert.DoesNotContain("Strom flow", energyPage, StringComparison.Ordinal);
Assert.DoesNotContain("Top-level throughput", energyPage, StringComparison.Ordinal);
var flowTab = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri($"/energy/{electricityTypeId}?tab=flow&from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Manage connections", flowTab, StringComparison.Ordinal);
Assert.Contains("The flow as a table", flowTab, StringComparison.Ordinal);
Assert.Contains("Input of a calculated sum", flowTab, StringComparison.Ordinal);
var metersTab = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri($"/energy/{electricityTypeId}?tab=meters&from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Breakdown of a counted meter", metersTab, StringComparison.Ordinal);
// ...each meter link carrying the page's period into the meter's Analysis tab.
Assert.Contains($"href=\"/meters/{hausId}?tab=analysis&from=2025-01-01&to=2025-12-31\"", metersTab, StringComparison.Ordinal);
// The meter list shares the type tab's list: what each meter measured and how it counts, the calculated Summe
// Solar marked as an analysis-only view, each name opening the meter's Analysis tab.
var meterList = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri("/meters", UriKind.Relative)));
Assert.Contains("Counts as", meterList, StringComparison.Ordinal);
Assert.Contains("Analysis only", meterList, StringComparison.Ordinal);
Assert.Contains($"href=\"/meters/{hausId}?tab=analysis\"", meterList, StringComparison.Ordinal);
// Manual entry is reachable without an API key or a CSV: the Readings tab of a real
// (non-virtual) meter offers it, prefilled with that meter's last register value.
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
@@ -118,9 +244,39 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
Assert.Contains("Record event", meterPage, StringComparison.Ordinal);
Assert.Contains("Edit meter", meterPage, StringComparison.Ordinal);
// The tab bar sits under the header, keyed tabs (D-47), with the analysis in the default tab: the quality
// section only renders once the meter's analysis loaded (prerendered, D-46).
Assert.Contains("Normalized data", meterPage, StringComparison.Ordinal);
Assert.Contains("Data quality and coverage", meterPage, StringComparison.Ordinal);
Assert.DoesNotContain("Meter register details", meterPage, StringComparison.Ordinal);
// A deep link into a tab and an action renders — the action itself only opens once the
// page is interactive, which a prerender request never is.
(await client.GetAsync(new Uri($"/meters/{hausId}?tab=events&action=swap", UriKind.Relative))).EnsureSuccessStatusCode();
// page is interactive, which a prerender request never is. The events tab it names is the one shown.
var swapLink = await client.GetStringAsync(new Uri($"/meters/{hausId}?tab=events&action=swap", UriKind.Relative));
Assert.Contains("Record a meter swap or a counter reset here", swapLink, StringComparison.Ordinal);
// Old links keep working: the legacy consumption tab opens Normalized data.
var legacyTab = await client.GetStringAsync(new Uri($"/meters/{hausId}?tab=consumption", UriKind.Relative));
Assert.Contains("what charts, totals and costs are built from", legacyTab, StringComparison.Ordinal);
// A virtual meter gets the same analysis from its formula (no register details, no readings), and its
// Sources link opens the Calculation tab with the formula's meters by name.
int summePageId;
await using (var db = fx.CreateContext())
{
summePageId = await db.Meters.Where(m => m.Name == "Summe Solar").Select(m => m.Id).FirstAsync();
}
var virtualPage = System.Net.WebUtility.HtmlDecode(
await client.GetStringAsync(new Uri($"/meters/{summePageId}", UriKind.Relative)));
Assert.Contains("Data quality and coverage", virtualPage, StringComparison.Ordinal);
Assert.Contains("Calculation", virtualPage, StringComparison.Ordinal);
Assert.DoesNotContain("Add reading", virtualPage, StringComparison.Ordinal);
var calculation = System.Net.WebUtility.HtmlDecode(
await client.GetStringAsync(new Uri($"/meters/{summePageId}?tab=sources", UriKind.Relative)));
Assert.Contains("Meters in the formula", calculation, StringComparison.Ordinal);
Assert.Contains("Zähler Solar 1", calculation, StringComparison.Ordinal);
Assert.Contains("Zähler Solar 2", calculation, StringComparison.Ordinal);
// The way back from setting up a connector for a source: the connector page names the meter
// and links to its source dialog, and lists which meters each connector serves.
@@ -136,9 +292,36 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var importPage = await client.GetStringAsync(new Uri("/import", UriKind.Relative));
Assert.Contains($"href=\"/meters/{hausId}\"", importPage, StringComparison.Ordinal);
// Regression: /trends started with its load guard set, so it never left the spinner.
var trends = await client.GetStringAsync(new Uri("/trends", UriKind.Relative));
Assert.Contains("Total over range", trends, StringComparison.Ordinal);
// The Analysis page (/trends, brief §7.4) renders its figures in the prerender — it used to never leave the
// spinner. The portfolio cost of 2025 is the sheet's Jahreskosten (D-44, 7.907,64 € ± 0,02 €), manual Heizung
// costs included once; a category whose meters measure different things is explained, not charted as one
// quantity; and two meters compare side by side.
var trends = System.Net.WebUtility.HtmlDecode(
await client.GetStringAsync(new Uri("/trends?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains(">Analysis</h1>", trends, StringComparison.Ordinal);
Assert.Contains("Total cost", trends, StringComparison.Ordinal);
Assert.Contains("7,907.65", trends, StringComparison.Ordinal);
Assert.Contains("Manual costs", trends, StringComparison.Ordinal);
Assert.Contains("Values per period", trends, StringComparison.Ordinal);
Assert.DoesNotContain("Total over range", trends, StringComparison.Ordinal);
int stromCategoryId, netzId;
await using (var db = fx.CreateContext())
{
stromCategoryId = await db.CostCategories.Where(c => c.Name == "Strom").Select(c => c.Id).FirstAsync();
netzId = await db.Meters.Where(m => m.Name == "Zähler Netz").Select(m => m.Id).FirstAsync();
}
var mixedCategory = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
new Uri($"/trends?scope=category&id={stromCategoryId}&metric=consumption", UriKind.Relative)));
Assert.Contains("measure different things", mixedCategory, StringComparison.Ordinal);
Assert.DoesNotContain("Values per period", mixedCategory, StringComparison.Ordinal);
var meterComparison = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
new Uri($"/trends?scope=meters&ids={hausId},{netzId}&from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Zähler Haus", meterComparison, StringComparison.Ordinal);
Assert.Contains("Zähler Netz", meterComparison, StringComparison.Ordinal);
Assert.Contains("Values per period", meterComparison, StringComparison.Ordinal);
// A tank's page leads with the entry that drives it (a tank level), not a reading nothing reads.
int tankId;
@@ -151,6 +334,22 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
Assert.Contains("Record tank level", tankPage, StringComparison.Ordinal);
var consumablesPage = await client.GetStringAsync(new Uri("/consumables", UriKind.Relative));
Assert.Contains($"/meters/{tankId}?tab=events&amp;action=delivery", consumablesPage, StringComparison.Ordinal);
// The tank's state now stays apart from the selected period (D-54): the last dipstick as measured, the contents
// estimated from it, and a forecast that is a projection or says why there is none.
Assert.Contains("Last dipstick", consumablesPage, StringComparison.Ordinal);
Assert.Contains("Estimated now (incl. deliveries since)", consumablesPage, StringComparison.Ordinal);
Assert.Contains("Selected period", consumablesPage, StringComparison.Ordinal);
// A year of the sheet on Solar: the figures with their units, and a setup card for the one role nobody holds
// (grid export) that names it in words — never the raw role tokens of the old hint.
var solarYear = System.Net.WebUtility.HtmlDecode(
await client.GetStringAsync(new Uri("/solar?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Self-consumption", solarYear, StringComparison.Ordinal);
Assert.Contains("kWh", solarYear, StringComparison.Ordinal);
Assert.Contains("Grid export: no meter yet", solarYear, StringComparison.Ordinal);
Assert.Contains($"/meters/{hausId}?tab=analysis", solarYear, StringComparison.Ordinal);
Assert.DoesNotContain("total_load", solarYear, StringComparison.Ordinal);
Assert.DoesNotContain("grid_import", solarYear, StringComparison.Ordinal);
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
// this covers the labels that only exist once rows have rendered — the branch a
@@ -167,8 +366,24 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
await germanClient.GetStringAsync(new Uri("/", UriKind.Relative)));
Assert.Contains("lang=\"de\"", germanOverview, StringComparison.Ordinal);
Assert.Contains("Übersicht", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("This month", germanOverview, StringComparison.Ordinal);
Assert.Contains("Spezialansichten", germanOverview, StringComparison.Ordinal);
Assert.Contains("Konfiguration", germanOverview, StringComparison.Ordinal);
Assert.Contains("Datenimport", germanOverview, StringComparison.Ordinal);
Assert.Contains("Letzter Monat mit Daten: Mai 2026", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("Latest month with data", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("Total cost", germanOverview, StringComparison.Ordinal);
// MudBlazor's own accessible names speak German too (brief §8): a nav group's toggle, not "Toggle …".
Assert.Contains("aria-label=\"Energiearten ein- oder ausklappen\"", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("aria-label=\"Toggle ", germanOverview, StringComparison.Ordinal);
// The Analysis page in German: the same 2025 bill, in German words and number format.
var germanTrends = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/trends?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Gesamtkosten", germanTrends, StringComparison.Ordinal);
Assert.Contains("7.907,65", germanTrends, StringComparison.Ordinal);
Assert.Contains("Manuelle Kosten", germanTrends, StringComparison.Ordinal);
Assert.DoesNotContain("Total cost", germanTrends, StringComparison.Ordinal);
// Meter names are user data: they stay exactly as imported, in either language. The
// meter list is where they render — the overview shows cost categories, not meters.
@@ -178,6 +393,16 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
// ...while the meter's mode, which is an enum and not user data, is translated.
Assert.Contains("Zählerstand (kumulativ)", germanMeters, StringComparison.Ordinal);
Assert.DoesNotContain("CumulativeCounter", germanMeters, StringComparison.Ordinal);
Assert.Contains("Zählt als", germanMeters, StringComparison.Ordinal);
// The energy type page in German: the measures and tabs are worded, the type's name is not.
var germanEnergy = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri($"/energy/{electricityTypeId}?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains(">Strom</h1>", germanEnergy, StringComparison.Ordinal);
Assert.Contains("Gesamtverbrauch", germanEnergy, StringComparison.Ordinal);
Assert.Contains("Netzbezug", germanEnergy, StringComparison.Ordinal);
Assert.Contains("Abrechnung nach Netzbezug", germanEnergy, StringComparison.Ordinal);
Assert.DoesNotContain("Total use", germanEnergy, StringComparison.Ordinal);
foreach (var path in new[]
{
@@ -190,6 +415,16 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var response = await germanClient.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode();
}
// The specialized views in German: the roles and figures in words, the tank's two parts labelled.
var germanSolar = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/solar?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
Assert.Contains("Eigenverbrauch", germanSolar, StringComparison.Ordinal);
Assert.Contains("Netzeinspeisung: noch kein Zähler", germanSolar, StringComparison.Ordinal);
var germanConsumables = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/consumables", UriKind.Relative)));
Assert.Contains("Letzte Peilung", germanConsumables, StringComparison.Ordinal);
Assert.Contains("Gewählter Zeitraum", germanConsumables, StringComparison.Ordinal);
}
finally
{
@@ -198,6 +433,11 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
}
}
/// <summary>The <c>aria-expanded</c> of a navigation group's toggle in rendered HTML.</summary>
private static string GroupExpanded(string html, string group) =>
System.Text.RegularExpressions.Regex.Match(
html, "aria-expanded=\"(true|false)\" aria-label=\"Toggle " + System.Text.RegularExpressions.Regex.Escape(group) + "\"").Groups[1].Value;
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
@@ -0,0 +1,104 @@
using System.Net;
using MeterVault.App;
using MeterVault.Core.Domain;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests.Editor;
/// <summary>
/// The tariff and settings pages as the server prerenders them (D-37, D-52, D-57): a scoped tariff link lists what can
/// price that meter and nothing else, Bonus/Discount/Tax say they are not applied, and settings label raw retention as
/// not enforced, with the reason, beside the analysis data state.
/// </summary>
[Collection("Timescale")]
public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
{
private short _type;
private short _otherType;
private int _meter;
private readonly List<int> _tariffs = [];
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
var type = new EnergyType { Key = $"tariffs-{Guid.NewGuid():N}", DisplayName = "Tariff water", BaseUnit = "m3", DefaultMode = MeterMode.CumulativeCounter };
var other = new EnergyType { Key = $"tariffs-{Guid.NewGuid():N}", DisplayName = "Tariff heat", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.AddRange(type, other);
await db.SaveChangesAsync();
_type = type.Id;
_otherType = other.Id;
var meter = new Meter { Name = "Tap meter", EnergyTypeId = _type, Mode = MeterMode.CumulativeCounter, Unit = "m3", Meta = "{}" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meter = meter.Id;
Tariff[] tariffs =
[
Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.2345, "EUR/m3"),
Tariff(TariffScope.EnergyType, _type, TariffComponent.Bonus, 2.3456, "EUR"),
Tariff(TariffScope.EnergyType, _otherType, TariffComponent.UnitPrice, 9.8765, "EUR/kWh"),
];
db.Tariffs.AddRange(tariffs);
await db.SaveChangesAsync();
_tariffs.AddRange(tariffs.Select(t => t.Id));
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await db.Tariffs.Where(t => _tariffs.Contains(t.Id)).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == _meter).ExecuteDeleteAsync();
await db.EnergyTypes.Where(t => t.Id == _type || t.Id == _otherType).ExecuteDeleteAsync();
}
[Fact]
public async Task A_scoped_tariff_link_lists_what_can_price_the_meter()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
var scoped = WebUtility.HtmlDecode(await client.GetStringAsync(new Uri($"{TariffLinks.Path}?scope=meter&id={_meter}", UriKind.Relative)));
Assert.Contains("Tariffs that can price Tap meter: its own, its energy type's and global ones.", scoped, StringComparison.Ordinal);
Assert.Contains("1.2345", scoped, StringComparison.Ordinal); // its own price
Assert.Contains("2.3456", scoped, StringComparison.Ordinal); // its type's bonus …
Assert.Contains("Not applied yet", scoped, StringComparison.Ordinal); // … which is not applied yet
Assert.DoesNotContain("9.8765", scoped, StringComparison.Ordinal); // another type's price is not listed
Assert.Contains("Bonus, discount and tax tariffs are stored but not applied to costs yet.", scoped, StringComparison.Ordinal);
var all = WebUtility.HtmlDecode(await client.GetStringAsync(new Uri(TariffLinks.Path, UriKind.Relative)));
Assert.Contains("9.8765", all, StringComparison.Ordinal);
Assert.DoesNotContain("Tariffs that can price", all, StringComparison.Ordinal);
// The deep link of a missing price renders; its dialog opens only once the page is interactive.
var link = TariffLinks.New(TariffScope.Meter, _meter, TariffComponent.UnitPrice, new DateOnly(2027, 1, 1));
(await client.GetAsync(new Uri(link, UriKind.Relative))).EnsureSuccessStatusCode();
}
[Fact]
public async Task Settings_label_raw_retention_as_not_enforced_in_both_languages()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
var english = WebUtility.HtmlDecode(await client.GetStringAsync(new Uri("/admin/settings", UriKind.Relative)));
Assert.Contains("Not enforced", english, StringComparison.Ordinal);
Assert.Contains("Raw readings are kept indefinitely (configured: 1095 days).", english, StringComparison.Ordinal);
Assert.Contains("Analysis data", english, StringComparison.Ordinal);
Assert.Contains("Meters with current analysis data", english, StringComparison.Ordinal);
Assert.DoesNotContain("could not be read", english, StringComparison.Ordinal);
using var german = factory.CreateClient();
german.DefaultRequestHeaders.Add(
"Cookie",
CookieRequestCultureProvider.DefaultCookieName + "="
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
var deutsch = WebUtility.HtmlDecode(await german.GetStringAsync(new Uri("/admin/settings", UriKind.Relative)));
Assert.Contains("Nicht aktiv", deutsch, StringComparison.Ordinal);
Assert.Contains("Auswertungsdaten", deutsch, StringComparison.Ordinal);
}
private static Tariff Tariff(TariffScope scope, int id, TariffComponent component, double value, string unit) =>
new() { ScopeType = scope, ScopeId = id, Component = component, Value = value, Unit = unit, ValidFrom = new DateOnly(2020, 1, 1) };
}
@@ -0,0 +1,292 @@
using MeterVault.App.Analysis;
using MeterVault.App.MeterEditing;
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;
namespace MeterVault.Integration.Tests.Editor;
/// <summary>
/// The meter editor's live preview (D-31): an unsaved calculation evaluated through the shared reader over the stored
/// sources, with nothing written. The brief's worked example (§5.4): A = 100/80 and B = 150/120 kWh give A+B = 250/200
/// and AB = 50/40, and a source month that is missing makes the result's month incomplete rather than a number.
/// </summary>
[Collection("Timescale")]
public sealed class MeterDraftPreviewTests(TimescaleFixture fx) : IAsyncLifetime
{
private const string BerlinId = "Europe/Berlin";
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId);
/// <summary>The frozen "now" of every preview here (D-01).</summary>
private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime();
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.MeterLinks.Where(l => ids.Contains(l.FromMeterId) || ids.Contains(l.ToMeterId)).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 An_unsaved_sum_previews_the_worked_example_without_storing_anything()
{
var type = await TypeAsync();
var a = await GenerationAsync(type, 100, 80);
var b = await GenerationAsync(type, 150, 120);
var analysis = Analysis();
var catalog = await analysis.LoadCatalogAsync();
// A new meter: Sum mode picks A and B; nothing is declared, so the kind and unit come from the sources (A-08).
var draft = new MeterDraft(0, "A + B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} + m{b}") };
var validation = MeterDraftAnalysis.Validate(catalog, draft);
Assert.True(validation.IsSavable);
var effective = validation.EffectiveDefinition!;
Assert.Equal(QuantityKind.Generation, effective.ResultKind);
Assert.Equal(VirtualCostRule.None, effective.CostRule); // a generation sum is not costed by default (A-15)
var result = await analysis.PreviewAsync(MeterDraftAnalysis.Overlay(catalog, draft, effective), draft, JanFeb());
var series = result.SeriesFor(MeterDraft.NewMeterId)!;
Assert.Equal(SeriesBasis.Virtual, series.Basis);
Assert.Equal(QuantityKind.Generation, series.Kind);
Assert.Equal("kWh", series.Unit);
AssertValues(series.Values, 250, 200);
AssertAvailable(series.Total, 450);
Assert.True(series.IsAdditive);
// Each source's own values, as the preview table shows them beside the result.
var sources = series.Contributions.ToDictionary(c => c.MeterId);
AssertValues(sources[a].Values, 100, 80);
AssertValues(sources[b].Values, 150, 120);
// Nothing was written: no meter, no link, no definition.
await using var db = fx.CreateContext();
Assert.False(await db.Meters.AnyAsync(m => m.EnergyTypeId == type && m.Mode == MeterMode.Virtual));
}
[Fact]
public async Task An_unsaved_difference_stays_negative_and_an_edited_meter_previews_its_draft()
{
var type = await TypeAsync();
var a = await GenerationAsync(type, 100, 80);
var b = await GenerationAsync(type, 150, 120);
var existing = await VirtualAsync(type, $"m{a} + m{b}");
var analysis = Analysis();
var catalog = await analysis.LoadCatalogAsync();
// Editing the stored sum into a difference: the preview shows the draft, not what is stored.
var draft = new MeterDraft(existing, "A B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} - m{b}") };
var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!;
Assert.Equal(VirtualCostRule.None, effective.CostRule);
var result = await analysis.PreviewAsync(MeterDraftAnalysis.Overlay(catalog, draft, effective), draft, JanFeb());
var series = result.SeriesFor(existing)!;
AssertValues(series.Values, -50, -40);
AssertAvailable(series.Total, -90);
Assert.Equal(-1d, series.Contributions.Single(c => c.MeterId == b).Coefficient);
// The stored definition is still the sum.
var stored = await analysis.PreviewAsync(catalog, draft, JanFeb());
AssertValues(stored.SeriesFor(existing)!.Values, 250, 200);
}
[Fact]
public async Task A_missing_source_month_makes_the_preview_month_incomplete_not_a_number()
{
var type = await TypeAsync();
var a = await GenerationAsync(type, 100, 80);
var b = await GenerationAsync(type, 150); // B has January only
var analysis = Analysis();
var catalog = await analysis.LoadCatalogAsync();
var draft = new MeterDraft(0, "A + B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} + m{b}") };
var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!;
var series = (await analysis.PreviewAsync(MeterDraftAnalysis.Overlay(catalog, draft, effective), draft, JanFeb()))
.SeriesFor(MeterDraft.NewMeterId)!;
AssertAvailable(series.Values[0], 250);
Assert.NotEqual(BucketStatus.Available, series.Values[1].Status);
Assert.NotEqual(80, series.Values[1].Value);
Assert.NotEqual(BucketStatus.Available, series.Total.Status);
}
[Fact]
public async Task A_draft_that_reads_a_calculation_reading_it_is_a_named_loop()
{
var type = await TypeAsync();
var a = await GenerationAsync(type, 100, 80);
var first = await VirtualAsync(type, $"m{a}");
var second = await VirtualAsync(type, $"m{first}");
var catalog = await Analysis().LoadCatalogAsync();
var draft = new MeterDraft(first, "First", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{second} + m{a}") };
var validation = MeterDraftAnalysis.Validate(catalog, draft);
Assert.False(validation.IsValid);
var cycle = Assert.Single(validation.Problems, p => p.Kind == VirtualProblemKind.DependencyCycle);
Assert.Equal([first, second, first], cycle.MeterIds);
}
[Fact]
public async Task The_preview_opens_on_the_page_period_and_reaches_history_older_than_24_months()
{
// Brief §5.1 / D-31: "a preview for the selected historical period" — the page's period carries into the editor,
// and all available history spans the sources' data however old it is (here 2022, beyond every relative preset).
var type = await TypeAsync();
var a = await GenerationAsync(type, new DateOnly(2022, 1, 1), 100, 80);
var b = await GenerationAsync(type, new DateOnly(2022, 1, 1), 150, 120);
var analysis = Analysis();
var catalog = await analysis.LoadCatalogAsync();
var draft = new MeterDraft(0, "A + B", type, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{a} + m{b}") };
var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!;
draft = draft with { Definition = effective };
var overlay = MeterDraftAnalysis.Overlay(catalog, draft, effective);
// Without a page period the preview opens on the last 12 months — where these sources have nothing.
Assert.Equal(PeriodPreset.Last12Months, VirtualPreviewPeriod.Initial(null).Period);
// Opened from /meters/..?from=2022-01-01&to=2022-02-28 it shows exactly that range.
var page = AnalysisQuery.Parse("?from=2022-01-01&to=2022-02-28&bucket=day&compare=none", AnalysisDefaults.History);
var initial = VirtualPreviewPeriod.Initial(page);
Assert.True(initial.IsCustom);
var period = await VirtualPreviewPeriod.ResolveAsync(analysis, overlay, draft, initial, Now);
Assert.Equal(new DateOnly(2022, 1, 1), period.FirstDay);
var result = await analysis.PreviewAsync(overlay, draft, period);
AssertValues(result.SeriesFor(MeterDraft.NewMeterId)!.Values, 250, 200);
// All available history: the sources' own dates, and their values.
var all = await VirtualPreviewPeriod.ResolveAsync(analysis, overlay, draft, initial.WithPeriod(PeriodPreset.AllHistory), Now);
Assert.Equal(new DateOnly(2022, 1, 1), all.FirstDay);
var whole = await analysis.PreviewAsync(overlay, draft, all);
AssertAvailable(whole.SeriesFor(MeterDraft.NewMeterId)!.Total, 450);
}
// ------------------------------------------------------------------------------------------------ helpers
private MeterDraftAnalysis Analysis()
{
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId });
return new MeterDraftAnalysis(new AnalysisReader(fx, options), fx);
}
private static ResolvedPeriod JanFeb() =>
PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 28), Now, Berlin);
private static void AssertAvailable(BucketValue value, double expected)
{
Assert.Equal(BucketStatus.Available, value.Status);
Assert.Equal(expected, value.Value!.Value, 9);
}
private static void AssertValues(IReadOnlyList<BucketValue> values, params double[] expected)
{
Assert.Equal(expected.Length, values.Count);
for (var i = 0; i < expected.Length; i++)
{
AssertAvailable(values[i], expected[i]);
}
}
private async Task<short> TypeAsync()
{
await using var db = fx.CreateContext();
var type = new EnergyType
{
Key = $"editor-{Guid.NewGuid():N}",
DisplayName = "Editor test",
BaseUnit = "kWh",
DefaultMode = MeterMode.GenerationCounter,
};
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 = "{}", DateOnly? installedAt = null)
{
await using var db = fx.CreateContext();
var meter = new Meter
{
Name = $"editor-{Guid.NewGuid():N}",
EnergyTypeId = type,
Mode = mode,
Unit = "kWh",
InstalledAt = installedAt,
Meta = meta,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
return meter.Id;
}
/// <summary>A generation counter installed on 1 January 2026 whose months book the given amounts (read on the 1st after each).</summary>
private Task<int> GenerationAsync(short type, params double[] months) => GenerationAsync(type, new DateOnly(2026, 1, 1), months);
/// <summary>A generation counter installed on <paramref name="first"/> (a 1st) whose months book the given amounts.</summary>
private async Task<int> GenerationAsync(short type, DateOnly first, params double[] months)
{
var meter = await MeterAsync(type, MeterMode.GenerationCounter, installedAt: first);
var register = 0d;
await using (var db = fx.CreateContext())
{
for (var i = 0; i < months.Length; i++)
{
register += months[i];
db.Readings.Add(new Reading
{
MeterId = meter,
Time = GapAttribution.LocalMidnight(first.AddMonths(i + 1), Berlin).ToUniversalTime(),
Value = register,
Quality = ReadingQuality.Manual,
});
}
await db.SaveChangesAsync();
}
await RecomputeAsync(meter);
return meter;
}
private async Task<int> VirtualAsync(short type, string expression)
{
var meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, QuantityKind.Generation, "kWh", VirtualCostRule.None));
var meter = await MeterAsync(type, MeterMode.Virtual, meta);
await RecomputeAsync(meter);
return meter;
}
private async Task RecomputeAsync(int meterId)
{
await using var db = fx.CreateContext();
await using var tx = await db.Database.BeginTransactionAsync();
var normalization = new NormalizationService(
db,
NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }),
new FixedTimeProvider(Now));
await normalization.RecomputeMeterAsync(meterId, null);
await db.SaveChangesAsync();
await tx.CommitAsync();
}
}
@@ -0,0 +1,464 @@
using MeterVault.App.MeterEditing;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Integration.Tests.Analysis;
namespace MeterVault.Integration.Tests.Editor;
/// <summary>
/// The meter editor's rules without a browser or a database (brief §5.1, D-23, D-26, D-28, D-31, D-39, A-08, A-15): how
/// a calculation is written in Sum, Difference and Formula mode, which sources and cost rules are offered, when a save is
/// blocked, what a legacy meter proposes, and the words for a loop, a unit mismatch and a refused totals override. The
/// catalog is built from entities (<see cref="AnalysisCatalog.Build"/>), exactly as a request builds it.
/// </summary>
public sealed class MeterEditorLogicTests
{
private const int SolarA = 1;
private const int SolarB = 2;
private const int House = 3;
private const int Water = 4;
private const int SolarSum = 5;
private const int HalfHouse = 6;
private const int Draft = 7;
private const int ReadsDraft = 8;
private const int Garage = 9;
private const int Car = 10;
private const int Legacy = 11;
// ------------------------------------------------------------------------------------------------ writing a calculation
[Theory]
[InlineData("m1 + m2", CalculationMode.Sum, new[] { 1, 2 })]
[InlineData("m2 + m1", CalculationMode.Sum, new[] { 2, 1 })]
[InlineData("(m1) + m2", CalculationMode.Sum, new[] { 1, 2 })]
[InlineData("m3 - m1 - m2", CalculationMode.Difference, new[] { 3, 1, 2 })]
[InlineData("m3 - (m1 - m2)", CalculationMode.Advanced, new[] { 3, 1, 2 })]
[InlineData("(m1 - m2) * 0.5", CalculationMode.Advanced, new[] { 1, 2 })]
[InlineData("m1 + m1", CalculationMode.Advanced, new[] { 1, 1 })]
[InlineData("m1 + 5", CalculationMode.Advanced, new[] { 1 })]
public void A_stored_formula_reopens_in_the_mode_its_shape_allows(string expression, CalculationMode mode, int[] meters)
{
var draft = CalculationDraft.From(new VirtualDefinition(expression, QuantityKind.Consumption, "kWh", VirtualCostRule.None));
Assert.Equal(mode, draft.Mode);
switch (mode)
{
case CalculationMode.Sum:
Assert.Equal(meters, draft.SumSources);
break;
case CalculationMode.Difference:
Assert.Equal(meters[0], draft.Minuend);
Assert.Equal(meters[1..], draft.Subtrahends);
break;
default:
Assert.Equal(expression, draft.Text);
break;
}
// Whatever the mode, the text it writes is the same calculation.
Assert.Equal(Formula.Parse(expression), Formula.Parse(draft.Text));
}
[Fact]
public void Switching_modes_carries_the_meters_and_the_formula_text()
{
var draft = new CalculationDraft();
Assert.True(draft.IsIncomplete);
Assert.Equal(string.Empty, draft.Text);
draft.SetSumSources([SolarA, SolarB]);
Assert.Equal("m1 + m2", draft.Text);
draft.SetSumSources([SolarB, SolarA, House]); // picking a meter never reorders the ones already there
Assert.Equal("m1 + m2 + m3", draft.Text);
draft.SwitchTo(CalculationMode.Difference);
Assert.Equal("m1 - m2 - m3", draft.Text);
draft.SetMinuend(SolarB); // the start is never also subtracted
Assert.Equal("m2 - m3", draft.Text);
draft.SetSubtrahends([SolarB]);
Assert.True(draft.IsIncomplete); // nothing left to subtract
draft.SetSubtrahends([House, SolarA]);
draft.SwitchTo(CalculationMode.Advanced);
Assert.Equal("m2 - m3 - m1", draft.Expression);
draft.Expression = "m1 / m3";
draft.InsertReference(SolarB);
Assert.Equal("m1 / m3 + m2", draft.Expression);
draft.Expression = "m1 * (";
draft.InsertReference(House);
Assert.Equal("m1 * ( m3", draft.Expression);
// Back from a formula of another shape: the pickers start from the meters it names.
draft.Expression = "(m3 - m1) / m2";
draft.SwitchTo(CalculationMode.Sum);
Assert.Equal([House, SolarA, SolarB], draft.SumSources);
Assert.Equal(CalculationMode.Sum, draft.Mode);
}
[Fact]
public void The_definition_leaves_undeclared_parts_to_inference()
{
var draft = new CalculationDraft { ResultUnit = " " };
draft.SetSumSources([SolarA, SolarB]);
var definition = draft.Definition;
Assert.Equal("m1 + m2", definition.Expression);
Assert.Null(definition.ResultKind);
Assert.Null(definition.ResultUnit);
Assert.Null(definition.CostRule);
Assert.Equal([SolarA, SolarB], definition.ReferencedMeterIds);
}
// ------------------------------------------------------------------------------------------------ sources and cost rules
[Fact]
public void Sum_and_difference_pickers_offer_only_compatible_sources()
{
var catalog = Catalog();
var options = CalculationSources.For(catalog, MeterDraft.NewMeterId, energyTypeId: 1);
// Nothing picked: every meter that can be added up; the draft's own type first.
var open = CalculationSources.ForSum(options, []).Select(o => o.MeterId).ToList();
Assert.Contains(SolarA, open);
Assert.Contains(Water, open);
Assert.True(open.IndexOf(Water) > open.IndexOf(House), "meters of the draft's own energy type come first");
// Once a generation meter in kWh is picked, only generation in kWh fits a sum.
var afterSolar = CalculationSources.ForSum(options, [SolarA]).Select(o => o.MeterId).ToHashSet();
Assert.Equal(new HashSet<int> { SolarA, SolarB, SolarSum, Draft, ReadsDraft, Legacy }, afterSolar);
// A difference subtracts meters of its start's unit, of any additive kind (import minus export is a net balance).
var fromHouse = CalculationSources.ForSubtrahends(options, House, []).Select(o => o.MeterId).ToHashSet();
Assert.Contains(SolarA, fromHouse);
Assert.Contains(Garage, fromHouse);
Assert.DoesNotContain(House, fromHouse);
Assert.DoesNotContain(Water, fromHouse);
Assert.True(CalculationSources.MixesKinds(options, [House, SolarA]));
// Editing a meter never offers itself, nor a calculation that already reads it (that would close a loop).
var forDraft = CalculationSources.For(catalog, Draft, energyTypeId: 1).Select(o => o.MeterId).ToHashSet();
Assert.DoesNotContain(Draft, forDraft);
Assert.DoesNotContain(ReadsDraft, forDraft);
Assert.Contains(SolarSum, forDraft);
// Each option says what it measures, in its normalized unit.
var water = options.Single(o => o.MeterId == Water);
Assert.Equal("m³", water.Unit);
Assert.Equal(QuantityKind.Consumption, water.Kind);
Assert.Equal(new DateOnly(2021, 3, 1), options.Single(o => o.MeterId == SolarA).InstalledAt);
}
[Theory]
[InlineData("m3 + m9", null, new[] { VirtualCostRule.None, VirtualCostRule.SourceCosts, VirtualCostRule.OwnQuantity })]
[InlineData("m1 + m2", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })]
[InlineData("m3 - m9", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })]
[InlineData("0.5 * m3", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })]
[InlineData("m3 + 5", null, new[] { VirtualCostRule.None })]
[InlineData("m3 / m9", QuantityKind.Indicator, new[] { VirtualCostRule.None })]
[InlineData("m6 + m9", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })]
public void Cost_rules_are_offered_only_where_the_calculation_supports_them(string expression, QuantityKind? kind, VirtualCostRule[] offered)
{
var catalog = Catalog();
var validation = VirtualValidator.Validate(new VirtualDefinition(expression, kind, kind == QuantityKind.Indicator ? "%" : null), MeterDraft.NewMeterId, catalog.Catalog);
Assert.Equal(offered, CalculationSources.OfferedCostRules(validation.Formula, validation.Kind, MeterDraft.NewMeterId, catalog.Catalog));
}
[Fact]
public void A_rule_that_is_not_offered_says_why()
{
var catalog = Catalog().Catalog;
CostRuleUnavailable? Why(VirtualCostRule rule, string expression, QuantityKind? kind = null)
{
var v = VirtualValidator.Validate(new VirtualDefinition(expression, kind, kind == QuantityKind.Indicator ? "%" : null), MeterDraft.NewMeterId, catalog);
return CalculationSources.WhyNot(rule, v.Formula, v.Kind, MeterDraft.NewMeterId, catalog);
}
Assert.Null(Why(VirtualCostRule.SourceCosts, "m3 + m9"));
Assert.Equal(CostRuleBlock.Generation, Why(VirtualCostRule.SourceCosts, "m1 + m2")!.Reason);
Assert.Equal(CostRuleBlock.NotPureSum, Why(VirtualCostRule.SourceCosts, "m3 - m9")!.Reason);
Assert.Equal(new CostRuleUnavailable(CostRuleBlock.NestedNotPureSum, HalfHouse), Why(VirtualCostRule.SourceCosts, "m6 + m9"));
Assert.Equal(CostRuleBlock.NotLinear, Why(VirtualCostRule.OwnQuantity, "m3 + 5")!.Reason);
Assert.Equal(CostRuleBlock.Indicator, Why(VirtualCostRule.OwnQuantity, "m3 / m9", QuantityKind.Indicator)!.Reason);
Assert.Null(Why(VirtualCostRule.None, "m3 / m9", QuantityKind.Indicator));
}
// ------------------------------------------------------------------------------------------------ the calculation section
[Fact]
public void A_new_calculation_is_saved_only_once_it_is_complete_and_valid()
{
var model = new VirtualCalculationModel(Catalog(), MeterDraft.NewMeterId, energyTypeId: 1);
Assert.False(model.CanSave);
Assert.Null(model.EffectiveDefinition);
Assert.Empty(model.Problems); // incomplete asks for input, it does not report errors
model.Draft.SetSumSources([SolarA, SolarB]);
model.Refresh();
// A-08: what a save writes is the effective definition — the inferred kind, the canonical unit, the default rule.
Assert.True(model.CanSave);
Assert.Equal(QuantityKind.Generation, model.InferredKind);
Assert.Equal("kWh", model.InferredUnit);
Assert.Equal(VirtualCostRule.None, model.DefaultCostRule); // a generation sum is not costed (A-15)
Assert.Equal(new VirtualDefinition("m1 + m2", QuantityKind.Generation, "kWh", VirtualCostRule.None), model.EffectiveDefinition);
// A new sum links its sources in the flow view unless told not to (links never change the calculation).
Assert.True(model.OffersLinkSync);
Assert.True(model.SyncLinks);
Assert.Equal([SolarA, SolarB], model.LinksAfterSave);
model.SyncLinks = false;
Assert.Empty(model.LinksAfterSave);
// A unit mismatch blocks the save and says so.
model.Draft.SwitchTo(CalculationMode.Advanced);
model.Draft.Expression = "m1 + m4";
model.Refresh();
Assert.False(model.CanSave);
Assert.Contains(model.Problems, p => p.Kind == VirtualProblemKind.UnitMismatch);
}
[Fact]
public void A_cost_rule_the_formula_stops_supporting_falls_back_to_the_default()
{
var model = new VirtualCalculationModel(Catalog(), MeterDraft.NewMeterId, energyTypeId: 1);
model.Draft.SetSumSources([House, Garage]);
model.Refresh();
Assert.Equal(VirtualCostRule.SourceCosts, model.DefaultCostRule);
model.Draft.CostRule = VirtualCostRule.OwnQuantity;
model.Refresh();
Assert.False(model.CostRuleReset);
Assert.Equal(VirtualCostRule.OwnQuantity, model.EffectiveDefinition!.CostRule);
// m3 + 5 is not linear: the chosen rule is dropped rather than left as an error the user did not make.
model.Draft.SwitchTo(CalculationMode.Advanced);
model.Draft.Expression = "m3 + 5";
model.Refresh();
Assert.True(model.CostRuleReset);
Assert.Null(model.Draft.CostRule);
Assert.Equal([VirtualCostRule.None], model.OfferedCostRules);
Assert.Equal(VirtualCostRule.None, model.EffectiveDefinition!.CostRule);
}
[Fact]
public void A_source_cost_rule_over_a_nested_calculation_that_is_not_a_sum_blocks_the_save()
{
// The formula is a plain sum at its own level, but its source Half house is 0.5 × House (A-15): the validator's
// CostRuleProblem must stop a save that stores sourceCosts, even though the definition itself is valid.
var catalog = Catalog();
var validation = MeterDraftAnalysis.Validate(catalog, NewVirtual() with
{
Definition = new VirtualDefinition("m6 + m9", null, null, VirtualCostRule.SourceCosts),
});
Assert.True(validation.IsValid);
Assert.False(validation.IsSavable);
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, validation.CostRuleProblem!.Kind);
var text = AnalysisUiTestData.In("en", () => MeterEditorText.Problem(validation.CostRuleProblem, Name(catalog)));
Assert.Equal("“Sum of the sources' costs” needs plain sums throughout, but Half house is not a plain sum.", text);
}
[Fact]
public void A_stored_calculation_reopens_with_what_its_sources_imply_as_automatic()
{
// Solar sum is stored with everything declared (A-08); what merely repeats the sources shows as automatic, so a
// change of sources carries the kind along instead of contradicting it.
var model = new VirtualCalculationModel(Catalog(), SolarSum, energyTypeId: 1);
Assert.Equal(VirtualMeterStatus.Valid, model.Status);
Assert.Equal(CalculationMode.Sum, model.Draft.Mode);
Assert.Equal([SolarA, SolarB], model.Draft.SumSources);
Assert.Null(model.Draft.ResultKind);
Assert.Null(model.Draft.ResultUnit);
Assert.Null(model.Draft.CostRule);
Assert.True(model.CanSave);
Assert.False(model.OffersLinkSync); // its links already are its sources
}
[Fact]
public void A_legacy_meter_opens_with_its_implied_sum_as_a_proposal_to_confirm()
{
var model = new VirtualCalculationModel(Catalog(), Legacy, energyTypeId: 1);
Assert.True(model.IsLegacyProposal);
Assert.Equal(CalculationMode.Sum, model.Draft.Mode);
Assert.Equal([SolarA, SolarB], model.Draft.SumSources.Order());
Assert.True(model.CanSave);
Assert.Equal(QuantityKind.Generation, model.EffectiveDefinition!.ResultKind);
Assert.False(model.OffersLinkSync);
}
// ------------------------------------------------------------------------------------------------ findings in words
[Fact]
public void A_loop_is_named_by_its_path_in_both_languages()
{
var catalog = Catalog();
var validation = MeterDraftAnalysis.Validate(catalog, new MeterDraft(Draft, "Draft", 1, MeterMode.Virtual, "kWh")
{
Definition = new VirtualDefinition($"m{ReadsDraft}"),
});
var cycle = Assert.Single(validation.Problems, p => p.Kind == VirtualProblemKind.DependencyCycle);
Assert.Equal([Draft, ReadsDraft, Draft], cycle.MeterIds);
Assert.Equal(
"The calculation goes round in a circle: Draft → Reads draft → Draft.",
AnalysisUiTestData.In("en", () => MeterEditorText.Problem(cycle, Name(catalog))));
Assert.Equal(
"Die Berechnung dreht sich im Kreis: Draft → Reads draft → Draft.",
AnalysisUiTestData.In("de", () => MeterEditorText.Problem(cycle, Name(catalog))));
}
[Fact]
public void A_unit_mismatch_names_both_meters_and_their_units()
{
var catalog = Catalog();
var validation = MeterDraftAnalysis.Validate(catalog, NewVirtual() with { Definition = new VirtualDefinition("m1 + m4") });
var mismatch = Assert.Single(validation.Problems, p => p.Kind == VirtualProblemKind.UnitMismatch);
Assert.Equal(
"Solar A (kWh) and Water (m³) cannot be added or subtracted: their units differ.",
AnalysisUiTestData.In("en", () => MeterEditorText.Problem(mismatch, Name(catalog))));
Assert.Equal(
"Solar A (kWh) und Water (m³) lassen sich nicht addieren oder subtrahieren: Ihre Einheiten unterscheiden sich.",
AnalysisUiTestData.In("de", () => MeterEditorText.Problem(mismatch, Name(catalog))));
}
[Theory]
[InlineData("m1 +", "The formula ends where a meter or a number is expected.")]
[InlineData("m1 + n2", "“n2” is not a meter; refer to meters as m and their number, e.g. m12.")]
[InlineData("(m1 + m2", "The parenthesis at position 1 is never closed.")]
[InlineData("m1 % m2", "“%” is not allowed in a formula (position 4).")]
[InlineData("m1 + m3 + m99", "m99 is not a meter.")]
[InlineData("m1 + m2 - m3", "Solar A (Generation) and House (Consumption) measure different things; to combine them, set the result to Net.")]
[InlineData("m3 * m9", "Multiplying or dividing meters (House, Garage) gives a ratio: set the result to Indicator.")]
[InlineData("5", "The formula refers to no meter; a number alone is not a meter.")]
public void Validation_problems_read_as_sentences(string expression, string expected)
{
var catalog = Catalog();
var validation = MeterDraftAnalysis.Validate(catalog, NewVirtual() with { Definition = new VirtualDefinition(expression, KindFor(expression)) });
var texts = AnalysisUiTestData.In("en", () => validation.Problems.Select(p => MeterEditorText.Problem(p, Name(catalog))).ToList());
Assert.Contains(expected, texts);
}
// ------------------------------------------------------------------------------------------------ totals override (D-23)
[Fact]
public void Always_on_a_breakdown_is_refused_naming_the_counted_parent()
{
var catalog = Catalog();
var car = new MeterDraft(Car, "Car", 1, MeterMode.CumulativeCounter, "kWh");
var overlay = MeterDraftAnalysis.Overlay(catalog, car, null);
var always = MeterDraftAnalysis.CheckTotals(overlay, car, TotalsOverride.Always);
Assert.False(always.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, House), always.Conflict);
Assert.Equal(
"“Always count” would count energy twice: House is already counted and overlaps this meter.",
AnalysisUiTestData.In("en", () => MeterEditorText.Conflict(always.Conflict!, Name(catalog))));
Assert.True(MeterDraftAnalysis.CheckTotals(overlay, car, TotalsOverride.Never).IsAllowed);
Assert.True(MeterDraftAnalysis.CheckTotals(overlay, car, TotalsOverride.Auto).IsAllowed);
// Unlinked from House in the dialog, the same meter is a root of its own and may be counted.
var unlinked = car with { Upstream = [] };
Assert.True(MeterDraftAnalysis.CheckTotals(MeterDraftAnalysis.Overlay(catalog, unlinked, null), unlinked, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void A_new_virtual_draft_is_laid_over_the_catalog_with_its_effective_definition()
{
var catalog = Catalog();
var draft = NewVirtual() with { Definition = new VirtualDefinition("m1 + m2"), Upstream = [SolarA, SolarB] };
var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!;
var overlay = MeterDraftAnalysis.Overlay(catalog, draft, effective);
var meter = overlay.Find(MeterDraft.NewMeterId)!;
Assert.Equal(VirtualMeterStatus.Valid, meter.VirtualStatus);
Assert.Equal(QuantityKind.Generation, meter.Quantity.Kind);
Assert.Equal([SolarA, SolarB], overlay.Graph.DirectDependencies(MeterDraft.NewMeterId));
Assert.Equal(2, overlay.Links.Count(l => l.ToMeterId == MeterDraft.NewMeterId));
Assert.Equal(catalog.Meters.Count + 1, overlay.Meters.Count);
Assert.Equal(MeterTotalsClass.AnalysisOnly, overlay.Totals.Meters[MeterDraft.NewMeterId].Class);
// The stored catalog is untouched.
Assert.Null(catalog.Find(MeterDraft.NewMeterId));
}
[Fact]
public void The_totals_override_is_written_beside_every_other_key()
{
var meta = MeterDraftAnalysis.WithTotalsOverride("""{"role":"grid_import","expression":"m1"}""", TotalsOverride.Always);
Assert.Equal(TotalsOverride.Always, TotalsOverrideTokens.FromMeta(meta));
Assert.Equal("grid_import", MeterMeta.Role(meta));
Assert.Contains("\"expression\":\"m1\"", meta, StringComparison.Ordinal);
var auto = MeterDraftAnalysis.WithTotalsOverride(meta, TotalsOverride.Auto);
Assert.DoesNotContain("totals", auto, StringComparison.Ordinal);
Assert.Equal("grid_import", MeterMeta.Role(auto));
Assert.Equal("""{"totals":"never"}""", MeterDraftAnalysis.WithTotalsOverride("not json", TotalsOverride.Never));
}
// ------------------------------------------------------------------------------------------------ helpers
/// <summary>A declared kind where a case needs one: a product declared as consumption, a mixed sum declared as generation.</summary>
private static QuantityKind? KindFor(string expression) => expression switch
{
"m1 + m2 - m3" => QuantityKind.Generation,
_ when expression.Contains('*', StringComparison.Ordinal) => QuantityKind.Consumption,
_ => null,
};
private static MeterDraft NewVirtual() => new(0, "New", 1, MeterMode.Virtual, "kWh");
private static Func<int, string> Name(AnalysisCatalog catalog) =>
id => catalog.Find(id)?.Name ?? (id == MeterDraft.NewMeterId ? "New" : CalculationDraft.Token(id));
/// <summary>
/// Two solar meters and their stored sum, a house with a garage and a car below it, water in another type, a
/// calculation that is not a sum (half the house), an edited virtual meter another one reads, and a legacy sum.
/// </summary>
private static AnalysisCatalog Catalog()
{
static string Def(string expression, QuantityKind kind, string unit, VirtualCostRule rule) =>
VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, unit, rule));
Meter M(int id, string name, MeterMode mode, string unit = "kWh", short type = 1, string meta = "{}") =>
new() { Id = id, Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, Meta = meta };
var solarA = M(SolarA, "Solar A", MeterMode.GenerationCounter);
solarA.InstalledAt = new DateOnly(2021, 3, 1);
Meter[] meters =
[
solarA,
M(SolarB, "Solar B", MeterMode.GenerationCounter),
M(House, "House", MeterMode.CumulativeCounter),
M(Water, "Water", MeterMode.CumulativeCounter, "m3", type: 2),
M(SolarSum, "Solar sum", MeterMode.Virtual, meta: Def("m1 + m2", QuantityKind.Generation, "kWh", VirtualCostRule.None)),
M(HalfHouse, "Half house", MeterMode.Virtual, meta: Def("0.5 * m3", QuantityKind.Consumption, "kWh", VirtualCostRule.None)),
M(Draft, "Draft", MeterMode.Virtual, meta: Def("m1 + m2", QuantityKind.Generation, "kWh", VirtualCostRule.None)),
M(ReadsDraft, "Reads draft", MeterMode.Virtual, meta: Def("m7", QuantityKind.Generation, "kWh", VirtualCostRule.None)),
M(Garage, "Garage", MeterMode.CumulativeCounter),
M(Car, "Car", MeterMode.CumulativeCounter),
M(Legacy, "Legacy sum", MeterMode.Virtual),
];
MeterLink[] links =
[
new() { FromMeterId = SolarA, ToMeterId = SolarSum },
new() { FromMeterId = SolarB, ToMeterId = SolarSum },
new() { FromMeterId = House, ToMeterId = Car },
new() { FromMeterId = SolarA, ToMeterId = Legacy },
new() { FromMeterId = SolarB, ToMeterId = Legacy },
];
return AnalysisCatalog.Build(meters, [], links, [], AnalysisUiTestData.Berlin);
}
}
@@ -0,0 +1,240 @@
using MeterVault.App;
using MeterVault.App.TariffEditing;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Integration.Tests.Analysis;
namespace MeterVault.Integration.Tests.Editor;
/// <summary>
/// The tariff editor's link and unit rules (D-37, D-52): what <c>/admin/tariffs?scope=&amp;id=&amp;component=&amp;from=&amp;action=new</c>
/// asks for and lists, and which units a save accepts for the scope's normalized units — pure, no database.
/// </summary>
public sealed class TariffEditingTests
{
// ------------------------------------------------------------------------------------------------ value (D-38)
[Fact]
public void A_new_tariff_without_a_value_cannot_be_saved_while_a_typed_zero_is_a_deliberate_free_price()
{
// D-38: an explicit zero tariff is a valid zero — so it must be typed, never the default of an untouched field. The
// deep link that explains a missing price must not turn it into a free period with one click.
Assert.Equal(TariffValueVerdict.Missing, TariffValue.Check(null, TariffComponent.UnitPrice));
Assert.True(TariffValue.Check(null, TariffComponent.BasePrice).BlocksSave());
var zero = TariffValue.Check(0, TariffComponent.UnitPrice);
Assert.Equal(TariffValueVerdict.FreeOfCharge, zero);
Assert.False(zero.BlocksSave());
Assert.Equal(TariffValueVerdict.FreeOfCharge, TariffValue.Check(0, TariffComponent.BasePrice));
Assert.Equal(TariffValueVerdict.Valid, TariffValue.Check(0.31, TariffComponent.UnitPrice));
Assert.Equal(TariffValueVerdict.Valid, TariffValue.Check(0, TariffComponent.Tax));
AnalysisUiTestData.In("en", () =>
{
Assert.Equal("A price of 0 makes this period free of charge.", TariffValue.Note(TariffValueVerdict.FreeOfCharge));
Assert.Equal("Enter a value.", TariffValue.Note(TariffValueVerdict.Missing));
Assert.Null(TariffValue.Note(TariffValueVerdict.Valid));
});
}
// ------------------------------------------------------------------------------------------------ deep link (D-52)
[Fact]
public void A_missing_price_link_round_trips_into_a_prefilled_new_tariff()
{
var url = TariffLinks.New(TariffScope.Meter, 12, TariffComponent.UnitPrice, new DateOnly(2024, 1, 17));
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
var link = TariffDeepLink.Parse(query["scope"], query["id"], query["component"], query["from"], query["action"]);
Assert.Equal(new TariffDeepLink(TariffScope.Meter, 12, TariffComponent.UnitPrice, new DateOnly(2024, 1, 1), OpenNew: true), link);
Assert.True(link.HasScope);
}
[Theory]
[InlineData("type", "3", "feed-in", "2026-02-01", "new", TariffScope.EnergyType, 3, TariffComponent.FeedIn, true)]
[InlineData("Meter", "7", "UnitPrice", null, null, TariffScope.Meter, 7, TariffComponent.UnitPrice, false)]
[InlineData("global", "5", "base-price", "2025-06-01", "NEW", TariffScope.Global, null, TariffComponent.BasePrice, true)]
public void Scope_component_date_and_action_are_read_case_insensitively(
string scope, string id, string component, string? from, string? action,
TariffScope expectedScope, int? expectedId, TariffComponent expectedComponent, bool openNew)
{
var link = TariffDeepLink.Parse(scope, id, component, from, action);
Assert.Equal(expectedScope, link.Scope);
Assert.Equal(expectedId, link.ScopeId); // global takes no id, whatever the link says
Assert.Equal(expectedComponent, link.Component);
Assert.Equal(from is null ? null : DateOnly.Parse(from, System.Globalization.CultureInfo.InvariantCulture), link.From);
Assert.Equal(openNew, link.OpenNew);
}
[Theory]
[InlineData("planet", "3", "unit-price", "2026-01-01", "new")]
[InlineData("type", null, "unit-price", "2026-01-01", "new")]
[InlineData("meter", "0", "unit-price", "2026-01-01", "new")]
[InlineData("meter", "-4", "unit-price", "2026-01-01", "new")]
[InlineData("meter", "abc", "unit-price", "2026-01-01", "new")]
public void A_scope_without_a_usable_target_scopes_nothing(string? scope, string? id, string? component, string? from, string? action)
{
var link = TariffDeepLink.Parse(scope, id, component, from, action);
Assert.Null(link.Scope);
Assert.Null(link.ScopeId);
Assert.False(link.HasScope);
Assert.True(link.OpenNew); // the dialog still opens, just not prefilled with a scope
Assert.Equal(TariffComponent.UnitPrice, link.Component);
}
[Fact]
public void Unknown_tokens_are_ignored_one_by_one()
{
var link = TariffDeepLink.Parse("meter", "4", "rebate", "31.12.2026", "open");
Assert.Equal(TariffScope.Meter, link.Scope);
Assert.Equal(4, link.ScopeId);
Assert.Null(link.Component);
Assert.Null(link.From);
Assert.False(link.OpenNew);
Assert.Equal(TariffDeepLink.None, TariffDeepLink.Parse(null, null, null, null, null));
}
[Fact]
public void A_scoped_list_shows_what_can_price_the_scope_in_precedence_order()
{
// Meter 12 and 13 are electricity (type 1), meter 20 is water (type 2).
int? TypeOf(int meter) => meter switch { 12 or 13 => 1, 20 => 2, _ => null };
var global = Tariff(TariffScope.Global, null);
var electricity = Tariff(TariffScope.EnergyType, 1);
var water = Tariff(TariffScope.EnergyType, 2);
var meter12 = Tariff(TariffScope.Meter, 12);
var meter13 = Tariff(TariffScope.Meter, 13);
var meter20 = Tariff(TariffScope.Meter, 20);
Tariff[] all = [global, electricity, water, meter12, meter13, meter20];
IEnumerable<Tariff> Listed(TariffDeepLink link) => all.Where(t => link.Lists(t, TypeOf));
Assert.Equal([global, electricity, meter12], Listed(TariffDeepLink.Parse("meter", "12", null, null, null)));
Assert.Equal([global, electricity, meter12, meter13], Listed(TariffDeepLink.Parse("type", "1", null, null, null)));
Assert.Equal([global], Listed(TariffDeepLink.Parse("global", null, null, null, null)));
Assert.Equal(all, Listed(TariffDeepLink.None));
}
// ------------------------------------------------------------------------------------------------ unit check (D-37)
[Theory]
[InlineData("EUR/kWh", "kWh", TariffUnitVerdictKind.Fits)]
[InlineData("ct/kWh", "kWh", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/MWh", "kWh", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/100 L", "L", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/m3", "m³", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/kWh brutto", "kWh", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/kWh", "m³", TariffUnitVerdictKind.Blocked)]
[InlineData("EUR/month", "kWh", TariffUnitVerdictKind.Blocked)]
[InlineData("USD/kWh", "kWh", TariffUnitVerdictKind.Blocked)]
[InlineData("pauschal", "kWh", TariffUnitVerdictKind.Warning)]
public void A_unit_price_must_be_quoted_per_the_unit_the_scope_bills(string unit, string meterUnit, TariffUnitVerdictKind expected)
{
var verdict = TariffUnitCheck.Check(unit, TariffComponent.UnitPrice, [new TariffUnitTarget("Netz", meterUnit)], "EUR");
Assert.Equal(expected, verdict.Kind);
Assert.Equal(expected == TariffUnitVerdictKind.Blocked, verdict.Blocks);
}
[Theory]
[InlineData("EUR/month", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/Tag", TariffUnitVerdictKind.Fits)]
[InlineData("€/Jahr inkl. MwSt.", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/Quartal", TariffUnitVerdictKind.Fits)]
[InlineData("EUR/kWh", TariffUnitVerdictKind.Blocked)]
[InlineData("EUR/2 Monate", TariffUnitVerdictKind.Blocked)]
[InlineData("CHF/month", TariffUnitVerdictKind.Blocked)]
[InlineData("monthly fee", TariffUnitVerdictKind.Warning)]
public void A_base_price_must_be_quoted_per_day_month_quarter_or_year(string unit, TariffUnitVerdictKind expected)
{
Assert.Equal(expected, TariffUnitCheck.Check(unit, TariffComponent.BasePrice, [], "EUR").Kind);
}
[Theory]
[InlineData(TariffComponent.Bonus)]
[InlineData(TariffComponent.Discount)]
[InlineData(TariffComponent.Tax)]
public void Bonus_discount_and_tax_are_stored_but_say_they_are_not_applied(TariffComponent component)
{
var verdict = TariffUnitCheck.Check("EUR/kWh", component, [new TariffUnitTarget("Wasser", "m³")], "EUR");
Assert.Equal(TariffUnitVerdictKind.NotApplied, verdict.Kind);
Assert.False(verdict.Blocks);
var lines = AnalysisUiTestData.In("en", () => TariffUnitCheck.Describe(verdict, "EUR"));
Assert.Equal([(false, "Bonus, discount and tax tariffs are stored but not applied to costs yet.")], lines);
}
[Fact]
public void A_global_price_over_several_units_fits_some_and_warns_about_the_rest()
{
TariffUnitTarget[] targets = [new("Netz", "kWh"), new("Wasser", "m³")];
var verdict = TariffUnitCheck.Check("EUR/kWh", TariffComponent.UnitPrice, targets, "EUR");
Assert.Equal(TariffUnitVerdictKind.Warning, verdict.Kind);
Assert.Equal(TariffUnitProblem.PartlyFits, verdict.Problem);
var lines = AnalysisUiTestData.In("en", () => TariffUnitCheck.Describe(verdict, "EUR"));
Assert.Equal(
[(false, "Read as EUR per kWh."), (false, "Fits Netz (kWh)."), (true, "Does not fit Wasser, which measures in m³.")],
lines);
}
[Fact]
public void The_verdict_reads_in_german_too()
{
var blocked = TariffUnitCheck.Check("EUR/kWh", TariffComponent.UnitPrice, [new TariffUnitTarget("Zähler Wasser", "m³")], "EUR");
var monthly = TariffUnitCheck.Check("EUR/month", TariffComponent.BasePrice, [], "EUR");
AnalysisUiTestData.In("de", () =>
{
Assert.Equal(
[(false, "Gelesen als EUR pro kWh."), (true, "Passt nicht zu Zähler Wasser (gemessen in m³).")],
TariffUnitCheck.Describe(blocked, "EUR"));
Assert.Equal([(false, "Gelesen als EUR pro Monat, verteilt auf die Tage dieses Zeitraums.")], TariffUnitCheck.Describe(monthly, "EUR"));
});
}
[Fact]
public void Targets_are_what_the_scope_bills_by_normalized_unit()
{
// Electricity: House (total load), Grid (grid import, billed), PV (generation), Export (grid export). Water: one meter.
Meter M(int id, string name, MeterMode mode, string unit, short type, string? role = null) => new()
{
Id = id, Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, Meta = role is null ? "{}" : MeterMeta.SetRole("{}", role),
};
Meter[] meters =
[
M(1, "House", MeterMode.CumulativeCounter, "kWh", 1, "total_load"),
M(2, "Grid", MeterMode.CumulativeCounter, "kWh", 1, "grid_import"),
M(3, "PV", MeterMode.GenerationCounter, "kWh", 1),
M(4, "Export", MeterMode.CumulativeCounter, "kWh", 1, "grid_export"),
M(5, "Water", MeterMode.CumulativeCounter, "m3", 2),
];
var catalog = AnalysisCatalog.Build(meters, [], [new MeterLink { FromMeterId = 2, ToMeterId = 1 }], [], AnalysisUiTestData.Berlin);
(string, string)? TypeInfo(int id) => id switch { 1 => ("Strom", "kWh"), 2 => ("Wasser", "m³"), 3 => ("Gas", "kWh"), _ => null };
Assert.Equal([new TariffUnitTarget("Grid", "kWh")], TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 1, TariffComponent.UnitPrice, TypeInfo));
Assert.Equal([new TariffUnitTarget("Export", "kWh")], TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 1, TariffComponent.FeedIn, TypeInfo));
Assert.Equal([new TariffUnitTarget("Water", "m³")], TariffUnitCheck.TargetsFor(catalog, TariffScope.Meter, 5, TariffComponent.UnitPrice, TypeInfo));
Assert.Equal(
[new TariffUnitTarget("Grid", "kWh"), new TariffUnitTarget("Water", "m³")],
TariffUnitCheck.TargetsFor(catalog, TariffScope.Global, null, TariffComponent.UnitPrice, TypeInfo));
// A type that bills nothing yet is checked against its base unit; a base price has no quantity to check.
Assert.Equal([new TariffUnitTarget("Gas", "kWh")], TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 3, TariffComponent.UnitPrice, TypeInfo));
Assert.Empty(TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 1, TariffComponent.BasePrice, TypeInfo));
Assert.Empty(TariffUnitCheck.TargetsFor(catalog, TariffScope.Meter, 99, TariffComponent.UnitPrice, TypeInfo));
// Nothing billed at all: the unit is read, and the check says it had nothing to compare with.
var none = TariffUnitCheck.Check("EUR/kWh", TariffComponent.FeedIn, [], "EUR");
Assert.Equal(TariffUnitProblem.NoTargets, none.Problem);
Assert.False(none.Blocks);
}
private static Tariff Tariff(TariffScope scope, int? id) =>
new() { ScopeType = scope, ScopeId = id, Component = TariffComponent.UnitPrice, Unit = "EUR/kWh", Value = 0.3 };
}
@@ -0,0 +1,293 @@
using MeterVault.App.Analysis;
using MeterVault.App.Energy;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The energy type page on real data (brief §7.3, D-22, D-30): the seeded Strom type reads use = Zähler Haus, grid
/// import = Zähler Netz, generation = Solar 1 + Solar 2 with Summe Solar analysis-only and Zähler Auto a breakdown of
/// Haus; its bill is the grid import; the flow draws the very same totals; and the History's individual view says how
/// each meter counts. An overlapping topology — a parent of 300 with a child of 100 — totals 300, never 400.
/// </summary>
[Collection("Timescale")]
public sealed class EnergyTypePageTests(TimescaleFixture fx)
{
/// <summary>The reference data's last full calendar year, read on a frozen "now" after it.</summary>
private static readonly DateTimeOffset Now = new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task The_seeded_strom_type_counts_each_meter_once()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using (var scope = factory.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<ReferenceDataImporter>().LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
try
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
var reader = services.GetRequiredService<AnalysisReader>();
Dictionary<string, int> id;
short strom;
List<MeterFacts> facts;
await using (var db = fx.CreateContext())
{
id = await db.Meters.ToDictionaryAsync(m => m.Name, m => m.Id);
strom = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
facts = await MeterFacts.LoadAsync(db, strom, default);
}
var period = PeriodResolver.Resolve(PeriodPreset.PreviousYear, null, null, Now, reader.Zone);
var result = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(strom), period)
{
Bucket = BucketSize.Month,
IncludeMeterSeries = true,
});
// The measures (D-22): each meter counted once, never a breakdown or a calculated view on top.
var use = Assert.Single(result.Measures, m => m.Key.Measure == TotalsMeasure.Use);
var grid = Assert.Single(result.Measures, m => m.Key.Measure == TotalsMeasure.GridImport);
var generation = Assert.Single(result.Measures, m => m.Key.Measure == TotalsMeasure.Generation);
Assert.DoesNotContain(result.Measures, m => m.Key.Measure is TotalsMeasure.Export or TotalsMeasure.Runtime);
Assert.Equal([id["Zähler Haus"]], use.MemberIds);
Assert.Equal([id["Zähler Netz"]], grid.MemberIds);
Assert.Equal(new[] { id["Zähler Solar 1"], id["Zähler Solar 2"] }.Order(), generation.MemberIds.Order());
double Total(string name) => result.SeriesFor(id[name])!.Total.Value!.Value;
Assert.Equal(BucketStatus.Available, use.Total.Status);
Assert.Equal(Total("Zähler Haus"), use.Total.Value!.Value, 6);
Assert.Equal(Total("Zähler Netz"), grid.Total.Value!.Value, 6);
Assert.Equal(Total("Zähler Solar 1") + Total("Zähler Solar 2"), generation.Total.Value!.Value, 6);
Assert.Equal(Total("Summe Solar"), generation.Total.Value!.Value, 6);
MeterTotalsEntry Entry(string name) => result.Classification.Single(c => c.MeterId == id[name]).Entry;
Assert.Equal(MeterTotalsClass.AnalysisOnly, Entry("Summe Solar").Class);
Assert.Equal(MeterTotalsClass.Breakdown, Entry("Zähler Auto").Class);
Assert.Equal([id["Zähler Haus"]], Entry("Zähler Auto").ParentIds);
Assert.Equal(MeterTotalsClass.GridImport, Entry("Zähler Netz").Class);
Assert.Equal(MeterTotalsClass.Use, Entry("Zähler Haus").Class);
// The bill is the grid import (D-34), not Haus + Netz + Auto.
var cost = await services.GetRequiredService<CostReader>().ReadAsync(
new CostAnalysisRequest(CostScope.ForEnergyType(strom), period) { Plan = result.Plan });
Assert.Equal(BillingBasis.GridImport, Assert.Single(cost.EnergyTypes).Basis);
Assert.Equal([id["Zähler Netz"]], cost.Lines.Select(l => l.MeterId).Distinct());
// The flow draws the same canonical totals: Summe Solar from its two calculated inputs.
var flow = await services.GetRequiredService<FlowService>().FromResultAsync(strom, result);
Assert.Equal(Total("Summe Solar"), flow.MeterFor(id["Summe Solar"])!.Value!.Value, 6);
Assert.Equal(Total("Zähler Haus"), flow.MeterFor(id["Zähler Haus"])!.Value!.Value, 6);
var inputs = flow.Links.Where(l => l.To == $"m{id["Summe Solar"]}").ToList();
Assert.Equal(2, inputs.Count);
Assert.All(inputs, l => Assert.True(l.IsCalculated));
Assert.Equal(facts.Count, flow.Meters.Count);
// The page's models: the meters say how they count, and the individual view explains the overlap.
var analysis = new EnergyAnalysis(strom, new EnergyTypeFacts(strom, "Strom", "kWh", null, null), facts, period, result, null, cost, null, null, flow,
new Dictionary<int, string>());
var rows = MeterListRows.Build(facts, result);
Assert.Equal(MeterTotalsClass.AnalysisOnly, rows.Single(r => r.Meter.Name == "Summe Solar").Membership!.Class);
Assert.All(rows.Where(r => r.Meter.Name is "Zähler Haus" or "Zähler Netz" or "Summe Solar"), r => Assert.True(r.HasValue));
var individual = EnergyHistoryView.Build(analysis, AnalysisMetric.Generation, individual: true, ComparisonRequest.None);
Assert.Equal(new[] { id["Zähler Solar 1"], id["Zähler Solar 2"], id["Summe Solar"] }.Order(), individual.Shown.Select(s => s.MeterId!.Value).Order());
var summe = individual.Memberships.Single(m => m.Series.MeterId == id["Summe Solar"]).Membership;
Assert.Equal(MeterTotalsClass.AnalysisOnly, summe.Class);
Assert.Contains("Zähler Solar 1", summe.Detail, StringComparison.Ordinal);
Assert.Contains("Zähler Solar 2", summe.Detail, StringComparison.Ordinal);
var total = EnergyHistoryView.Build(analysis, AnalysisMetric.Consumption, individual: false, ComparisonRequest.None);
Assert.Equal([TotalsMeasure.Use, TotalsMeasure.GridImport], total.Table.Select(t => t.Key).Select(k => result.Measures.Single(m => m.Key.Id == k).Key.Measure!.Value));
Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Cost], analysis.Metrics);
// The page itself: the type's own name as the title, the history's individual view with its explanations.
using var client = factory.CreateClient();
var html = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
new Uri($"/energy/{strom}?tab=history&view=meters&metric=consumption&period=prev-year", UriKind.Relative)));
Assert.Contains(">Strom</h1>", html, StringComparison.Ordinal);
Assert.Contains("How these meters count", html, StringComparison.Ordinal);
Assert.Contains("Part of Zähler Haus: shown, but never added on top.", html, StringComparison.Ordinal);
Assert.Contains("Individual meters", html, StringComparison.Ordinal);
}
finally
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
}
[Fact]
public async Task An_overlapping_topology_totals_the_parent_with_the_child_as_a_breakdown()
{
await using var data = new TopologyData(fx);
var type = await data.TypeAsync();
var parent = await data.MeterAsync(type, "Parent", 300);
var child = await data.MeterAsync(type, "Child", 100);
await data.LinkAsync(parent, child);
var reader = new AnalysisReader(fx);
var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31), Now, reader.Zone);
var result = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(type), period) { IncludeMeterSeries = true });
// 300, with 100 as a breakdown — not 400.
var use = Assert.Single(result.Measures);
Assert.Equal(TotalsMeasure.Use, use.Key.Measure);
Assert.Equal([parent], use.MemberIds);
Assert.Equal(300, use.Total.Value!.Value, 6);
Assert.Equal(100, result.SeriesFor(child)!.Total.Value!.Value, 6);
var entry = result.Classification.Single(c => c.MeterId == child).Entry;
Assert.Equal(MeterTotalsClass.Breakdown, entry.Class);
Assert.Equal([parent], entry.ParentIds);
// The meter list and the individual view show the child's own 100 and say it is part of the parent.
List<MeterFacts> facts;
await using (var db = fx.CreateContext())
{
facts = await MeterFacts.LoadAsync(db, type, default);
}
Analysis.AnalysisUiTestData.In("en", () =>
{
var rows = MeterListRows.Build(facts, result);
var childRow = rows.Single(r => r.Meter.Id == child);
Assert.Equal("100 kWh", childRow.ValueText);
Assert.Equal("Part of Parent: shown, but never added on top.", childRow.Membership!.Detail);
var analysis = new EnergyAnalysis(type, new EnergyTypeFacts(type, "Topology", "kWh", null, null), facts, period, result, null, null, null, null, null,
new Dictionary<int, string>());
var totalView = EnergyHistoryView.Build(analysis, AnalysisMetric.Consumption, individual: false, ComparisonRequest.None);
Assert.Single(totalView.Table);
Assert.Equal(300, totalView.Main!.Total.Value!.Value, 6);
var meters = EnergyHistoryView.Build(analysis, AnalysisMetric.Consumption, individual: true, ComparisonRequest.None);
Assert.Equal(2, meters.Shown.Count);
Assert.Contains(meters.Memberships, m => m.Series.MeterId == child && m.Membership.Class == MeterTotalsClass.Breakdown);
});
// The flow of the same result: 100 flows from the parent into the child, 200 stays "Other".
var flow = await new FlowService(fx).FromResultAsync(type, result);
Assert.Equal(100, flow.Links.Single(l => l.From == $"m{parent}" && l.To == $"m{child}").Value, 6);
Assert.Equal(200, flow.Nodes.Single(n => n.IsOther).Value, 6);
Assert.Equal(300, flow.Total, 6);
}
[Fact]
public async Task A_type_without_links_still_has_its_totals_and_an_empty_flow()
{
await using var data = new TopologyData(fx);
var type = await data.TypeAsync();
var a = await data.MeterAsync(type, "A", 40);
var b = await data.MeterAsync(type, "B", 60);
var reader = new AnalysisReader(fx);
var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31), Now, reader.Zone);
var result = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(type), period) { IncludeMeterSeries = true });
// No topology never stops the analysis: two unlinked consumption roots are the use.
var use = Assert.Single(result.Measures);
Assert.Equal(new[] { a, b }.Order(), use.MemberIds.Order());
Assert.Equal(100, use.Total.Value!.Value, 6);
var flow = await new FlowService(fx).FromResultAsync(type, result);
Assert.False(flow.HasChain);
Assert.Equal(2, flow.Meters.Count);
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
}
/// <summary>
/// A throwaway energy type with counters installed on 1 January 2024 and read once at the start of 2025 (UTC), so each
/// books its whole amount over 2024 — the setup of the flow tests — removed again on dispose.
/// </summary>
internal sealed class TopologyData(TimescaleFixture fx) : IAsyncDisposable
{
private readonly List<int> _meters = [];
private readonly List<short> _types = [];
public async Task<short> TypeAsync(string unit = "kWh")
{
await using var db = fx.CreateContext();
var type = new EnergyType { Key = $"energy-{Guid.NewGuid():N}", DisplayName = "Topology", BaseUnit = unit, DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_types.Add(type.Id);
return type.Id;
}
public async Task<int> MeterAsync(short type, string name, double? amount, MeterMode mode = MeterMode.CumulativeCounter, string meta = "{}")
{
await using var db = fx.CreateContext();
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh", InstalledAt = new DateOnly(2024, 1, 1), Meta = meta };
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
if (amount is { } value)
{
db.Readings.Add(new Reading
{
MeterId = meter.Id,
Time = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Value = value,
Quality = ReadingQuality.Manual,
});
await db.SaveChangesAsync();
}
if (mode != MeterMode.Virtual)
{
await new NormalizationService(db, NormalizationEngine.CreateDefault()).RecomputeMeterAsync(meter.Id, null);
await db.SaveChangesAsync();
}
return meter.Id;
}
public async Task LinkAsync(int from, int to)
{
await using var db = fx.CreateContext();
db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to });
await db.SaveChangesAsync();
}
public async ValueTask DisposeAsync()
{
await using var db = fx.CreateContext();
var ids = _meters.ToArray();
await db.MeterLinks.Where(l => ids.Contains(l.FromMeterId) || ids.Contains(l.ToMeterId)).ExecuteDeleteAsync();
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();
}
}
+204 -1
View File
@@ -1,3 +1,6 @@
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Backup;
using MeterVault.Infrastructure.Persistence;
@@ -7,7 +10,8 @@ namespace MeterVault.Integration.Tests;
/// <summary>
/// JSON config export/import (SDD §10): a round-trip through an emptied database preserves the
/// relationships (meter → energy type, meter-scoped tariff, category membership) after id remapping.
/// relationships (meter → energy type, meter-scoped tariff, category membership, meter topology links)
/// after id remapping — and the meter ids inside virtual-meter definitions follow their meters (D-32).
/// </summary>
[Collection("Timescale")]
public sealed class ExportRoundTripTests(TimescaleFixture fx)
@@ -57,6 +61,205 @@ public sealed class ExportRoundTripTests(TimescaleFixture fx)
}
}
[Fact]
public async Task Links_and_virtual_definitions_follow_their_meters_to_new_ids()
{
string json;
(int Meter, int Solar, int Sum, int Broken) old;
await using (var db = fx.CreateContext())
{
await WipeAllAsync(db);
await DatabaseSeeder.SeedAsync(db);
var elec = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var ghost = new Meter { Name = "Deleted before the export", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
var meter = new Meter { Name = "Export Haus", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
var solar = new Meter { Name = "Export Solar", EnergyTypeId = elec.Id, Mode = MeterMode.GenerationCounter, Unit = "kWh" };
var sum = new Meter { Name = "Export Net", EnergyTypeId = elec.Id, Mode = MeterMode.Virtual, Unit = "kWh" };
var broken = new Meter { Name = "Export Broken", EnergyTypeId = elec.Id, Mode = MeterMode.Virtual, Unit = "kWh" };
db.Meters.AddRange(ghost, meter, solar, sum, broken);
await db.SaveChangesAsync();
// A definition with a key of its own next to it (the role, a totals override, …), and one naming a meter the
// export will not contain.
sum.Meta = VirtualDefinitionJson.Write(
"""{"totals":"never"}""",
new VirtualDefinition($"(m{solar.Id} + m{meter.Id})", QuantityKind.Net, "kWh", VirtualCostRule.None));
broken.Meta = VirtualDefinitionJson.Write(
"{}", new VirtualDefinition($"m{solar.Id} + m{ghost.Id}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts));
db.MeterLinks.AddRange(
new MeterLink { FromMeterId = solar.Id, ToMeterId = sum.Id },
new MeterLink { FromMeterId = meter.Id, ToMeterId = sum.Id },
new MeterLink { FromMeterId = meter.Id, ToMeterId = solar.Id });
await db.SaveChangesAsync();
await db.Meters.Where(m => m.Id == ghost.Id).ExecuteDeleteAsync();
old = (meter.Id, solar.Id, sum.Id, broken.Id);
json = await new ExportService(db).ExportJsonAsync();
}
await using (var db = fx.CreateContext())
{
await WipeAllAsync(db);
await new ExportService(db).ImportJsonAsync(json);
}
await using (var db = fx.CreateContext())
{
var ids = await db.Meters.ToDictionaryAsync(m => m.Name, m => m.Id);
var (meter, solar, sum, broken) = (ids["Export Haus"], ids["Export Solar"], ids["Export Net"], ids["Export Broken"]);
// The restore numbered the meters anew, so a formula still naming the old ids would read other meters.
Assert.NotEqual(old.Solar, solar);
Assert.NotEqual(old.Meter, meter);
var links = await db.MeterLinks.Select(l => new { l.FromMeterId, l.ToMeterId }).ToListAsync();
Assert.Equal(
new[] { (meter, solar), (meter, sum), (solar, sum) }.Order(),
links.Select(l => (l.FromMeterId, l.ToMeterId)).Order());
var metas = await db.Meters.ToDictionaryAsync(m => m.Id, m => m.Meta);
var read = VirtualDefinitionJson.Read(metas[sum]);
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.Equal($"(m{solar} + m{meter})", read.Definition!.Expression);
Assert.Equal(new[] { meter, solar }.Order(), read.Definition.ReferencedMeterIds);
Assert.False(read.ReferencedIdsStale);
Assert.Equal(QuantityKind.Net, read.Definition.ResultKind);
Assert.Equal(VirtualCostRule.None, read.Definition.CostRule);
using (var doc = JsonDocument.Parse(metas[sum]))
{
Assert.Equal("never", doc.RootElement.GetProperty("totals").GetString());
}
// A meter the document has no row for becomes m0, which no meter has: an unknown reference, never another meter.
var brokenRead = VirtualDefinitionJson.Read(metas[broken]);
Assert.Equal($"m{solar} + m0", brokenRead.Definition!.Expression);
Assert.Equal([0, solar], brokenRead.Definition.ReferencedMeterIds);
Assert.False(await db.Meters.AnyAsync(m => m.Id == 0));
await WipeAllAsync(db);
}
}
[Fact]
public async Task A_tariff_of_a_deleted_meter_is_not_restored_onto_another_meter()
{
// Review virtual F2: tariff.scope_id has no foreign key, so deleting a meter the old way left its meter-scoped
// price behind. A fresh instance numbers its meters from where the original did, so the dead id is given to
// another restored meter, which the orphaned 0.99 EUR/kWh would then bill.
string json;
int firstId;
await using (var db = fx.CreateContext())
{
await WipeAllAsync(db);
await DatabaseSeeder.SeedAsync(db);
var elec = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var a = new Meter { Name = "Meter A", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
var b = new Meter { Name = "Old wallbox", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
var c = new Meter { Name = "Netz", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh", Meta = """{"role":"grid_import"}""" };
db.Meters.Add(a);
await db.SaveChangesAsync();
db.Meters.Add(b);
await db.SaveChangesAsync();
db.Meters.Add(c);
await db.SaveChangesAsync();
firstId = a.Id;
db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = b.Id, Component = TariffComponent.UnitPrice, Value = 0.99, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) });
await db.SaveChangesAsync();
// An orphan an older delete left behind: the meter goes, its price stays.
await db.Meters.Where(m => m.Id == b.Id).ExecuteDeleteAsync();
json = await new ExportService(db).ExportJsonAsync();
}
await using (var db = fx.CreateContext())
{
await WipeAllAsync(db);
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE meter ALTER COLUMN id RESTART WITH {firstId}");
await new ExportService(db).ImportJsonAsync(json);
}
await using (var db = fx.CreateContext())
{
var restored = await db.Meters.Select(m => m.Id).ToListAsync();
var meterTariffs = await db.Tariffs.Where(t => t.ScopeType == TariffScope.Meter).ToListAsync();
Assert.DoesNotContain(meterTariffs, t => restored.Contains(t.ScopeId!.Value));
Assert.Empty(meterTariffs);
await WipeAllAsync(db);
}
}
[Fact]
public async Task A_tariff_of_a_deleted_energy_type_is_not_restored_onto_another_type()
{
string json;
short firstId;
await using (var db = fx.CreateContext())
{
await WipeAllAsync(db);
var kept = new EnergyType { Key = "kept", DisplayName = "Kept", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
var gone = new EnergyType { Key = "gone", DisplayName = "Gone", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
var other = new EnergyType { Key = "other", DisplayName = "Other", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(kept);
await db.SaveChangesAsync();
db.EnergyTypes.Add(gone);
await db.SaveChangesAsync();
db.EnergyTypes.Add(other);
await db.SaveChangesAsync();
firstId = kept.Id;
db.Tariffs.Add(new Tariff { ScopeType = TariffScope.EnergyType, ScopeId = gone.Id, Component = TariffComponent.UnitPrice, Value = 0.99, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) });
await db.SaveChangesAsync();
await db.EnergyTypes.Where(t => t.Id == gone.Id).ExecuteDeleteAsync();
json = await new ExportService(db).ExportJsonAsync();
}
await using (var db = fx.CreateContext())
{
await WipeAllAsync(db);
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE energy_type ALTER COLUMN id RESTART WITH {firstId}");
await new ExportService(db).ImportJsonAsync(json);
}
await using (var db = fx.CreateContext())
{
Assert.Empty(await db.Tariffs.Where(t => t.ScopeType == TariffScope.EnergyType).ToListAsync());
await WipeAllAsync(db);
}
}
[Fact]
public async Task Deleting_a_meter_or_an_energy_type_takes_its_own_prices_with_it()
{
await using var db = fx.CreateContext();
await WipeAllAsync(db);
var type = new EnergyType { Key = "deleting", DisplayName = "Deleting", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
var meter = new Meter { Name = "Wallbox", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
db.Readings.Add(new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), Value = 1 });
db.Tariffs.AddRange(
new Tariff { ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice, Value = 0.99, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) },
new Tariff { ScopeType = TariffScope.EnergyType, ScopeId = type.Id, Component = TariffComponent.BasePrice, Value = 9, Unit = "EUR/month", ValidFrom = new DateOnly(2024, 1, 1) },
new Tariff { ScopeType = TariffScope.Global, Component = TariffComponent.BasePrice, Value = 1, Unit = "EUR/month", ValidFrom = new DateOnly(2024, 1, 1) });
await db.SaveChangesAsync();
await EntityDeletion.DeleteMeterAsync(db, meter.Id);
Assert.False(await db.Meters.AnyAsync(m => m.Id == meter.Id));
Assert.False(await db.Tariffs.AnyAsync(t => t.ScopeType == TariffScope.Meter));
await EntityDeletion.DeleteEnergyTypeAsync(db, type.Id);
Assert.False(await db.EnergyTypes.AnyAsync(t => t.Id == type.Id));
Assert.Equal(TariffScope.Global, (await db.Tariffs.SingleAsync()).ScopeType);
await WipeAllAsync(db);
}
private static async Task WipeAllAsync(MeterVaultDbContext db)
{
await db.Consumption.ExecuteDeleteAsync();
@@ -0,0 +1,9 @@
namespace MeterVault.Integration.Tests;
/// <summary>A clock stopped at one instant (D-01), so stamps written by the code under test are predictable.</summary>
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; set; } = now;
public override DateTimeOffset GetUtcNow() => Now.ToUniversalTime();
}
+291 -127
View File
@@ -1,182 +1,346 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
/// child's consumption proportionally to the parents' own consumption.
/// The per-energy-type flow graph (Sankey) on the shared analysis reader (D-30): node values are the meters' canonical
/// period totals — a virtual meter's through its formula; a single-parent chain attributes the child's full total to
/// its parent and shows the remainder as "Other"; a two-parent merge splits the child proportionally (an estimate);
/// a pure-sum virtual meter is fed by its calculation dependencies; any other virtual meter, and a meter in another
/// unit, is only in the table; links never carry more than the parent measured; a meter without data is never a zero.
/// </summary>
/// <remarks>
/// Every meter here is installed on 1 January 2024 and read once, at the first instant of 2025 (UTC, the zone of a
/// service built without options), so its whole amount accrues over 2024 and the year's total is fully covered.
/// Each test creates its own energy type and meters and removes them again.
/// </remarks>
[Collection("Timescale")]
public sealed class FlowServiceTests(TimescaleFixture fx)
public sealed class FlowServiceTests(TimescaleFixture fx) : IAsyncLifetime
{
private static readonly DateOnly Year = new(2024, 1, 1);
private static readonly DateOnly NextYear = new(2025, 1, 1);
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 Single_parent_chain_makes_other_remainder()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_a");
var main = await AddMeterAsync(db, "Main", type);
var car = await AddMeterAsync(db, "Car", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
await db.SaveChangesAsync();
var type = await TypeAsync();
var main = await MeterAsync(type, "Main", 100);
var car = await MeterAsync(type, "Car", 30);
await LinkAsync(main, car);
await AddConsumptionAsync(db, main.Id, 100);
await AddConsumptionAsync(db, car.Id, 30);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
Assert.Equal(100, graph.Total, 1);
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
var other = Assert.Single(graph.Nodes, n => n.IsOther);
Assert.Equal(70, other.Value, 1); // 100 30
}
finally
{
await ClearAsync(db);
}
Assert.Equal(100, graph.Total, 1);
var link = Assert.Single(graph.Links, l => l.To == $"m{car}");
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
Assert.False(link.IsEstimated);
Assert.False(link.IsCalculated);
var other = Assert.Single(graph.Nodes, n => n.IsOther);
Assert.Equal(70, other.Value, 1); // 100 30
Assert.Equal(BucketStatus.Available, other.Status);
Assert.Equal(BucketStatus.Available, graph.Nodes.Single(n => n.MeterId == main).Status);
}
[Fact]
public async Task Two_parents_split_child_proportionally()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_b");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar draw", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
var type = await TypeAsync();
var grid = await MeterAsync(type, "Grid", 75);
var solar = await MeterAsync(type, "Solar draw", 25);
var house = await MeterAsync(type, "House", 40);
await LinkAsync(grid, house);
await LinkAsync(solar, house);
await AddConsumptionAsync(db, grid.Id, 75);
await AddConsumptionAsync(db, solar.Id, 25);
await AddConsumptionAsync(db, house.Id, 40);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// House (40) splits 75:25 → 30 from grid, 10 from solar.
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
}
finally
{
await ClearAsync(db);
}
// House (40) splits 75:25 → 30 from grid, 10 from solar — an estimate, and marked so.
var fromGrid = graph.Links.Single(l => l.From == $"m{grid}" && l.To == $"m{house}");
var fromSolar = graph.Links.Single(l => l.From == $"m{solar}" && l.To == $"m{house}");
Assert.Equal(30, fromGrid.Value, 1);
Assert.Equal(10, fromSolar.Value, 1);
Assert.True(fromGrid.IsEstimated);
Assert.True(fromSolar.IsEstimated);
Assert.False(fromGrid.IsCapped);
}
[Fact]
public async Task Generation_meter_counts_as_source()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_c");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
var type = await TypeAsync();
var grid = await MeterAsync(type, "Grid", 75); // grid import
var solar = await MeterAsync(type, "Solar", 30, MeterMode.GenerationCounter); // solar generation
var house = await MeterAsync(type, "House", 40); // house load
await LinkAsync(grid, house);
await LinkAsync(solar, house);
await AddConsumptionAsync(db, grid.Id, 75); // grid import
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
await AddConsumptionAsync(db, house.Id, 40); // house load
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
// Remainder across grid+solar = (75+30) 40 = 65 (export + battery/inverter losses).
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
}
finally
{
await ClearAsync(db);
}
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid}" && l.To == $"m{house}").Value, 1);
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar}" && l.To == $"m{house}").Value, 1);
// Remainder across grid+solar = (75+30) 40 = 65 (export + battery/inverter losses).
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
Assert.Equal(QuantityKind.Generation, graph.MeterFor(solar)!.Kind);
}
[Fact]
public async Task Virtual_sum_meter_aggregates_its_upstreams()
public async Task Virtual_sum_meter_is_its_formula()
{
await using var db = fx.CreateContext();
try
var type = await TypeAsync();
var solar1 = await MeterAsync(type, "Solar 1", 15, MeterMode.GenerationCounter);
var solar2 = await MeterAsync(type, "Solar 2", 15, MeterMode.GenerationCounter);
var solar3 = await MeterAsync(type, "Solar 3", 5, MeterMode.GenerationCounter);
var sumSolar = await VirtualAsync(type, "Sum Solar", $"m{solar1} + m{solar2}", QuantityKind.Generation, VirtualCostRule.SourceCosts);
var grid = await MeterAsync(type, "Grid", 75);
var house = await MeterAsync(type, "House", 40);
// Solar1 + Solar2 → Sum Solar; Grid + Sum Solar → House. Solar 3 is linked into the sum too, but the formula —
// not a link — says what the sum is (D-25).
await LinkAsync(solar1, sumSolar);
await LinkAsync(solar2, sumSolar);
await LinkAsync(solar3, sumSolar);
await LinkAsync(grid, house);
await LinkAsync(sumSolar, house);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// Sum Solar has no readings but is Solar 1 + Solar 2 = 30, by its formula.
var node = graph.Nodes.Single(n => n.MeterId == sumSolar);
Assert.Equal(30, node.Value, 1);
Assert.True(node.IsVirtual);
Assert.Equal(SeriesBasis.Virtual, graph.MeterFor(sumSolar)!.Basis);
// Its incoming edges are its calculation dependencies, each at the source's value, marked calculated.
var incoming = graph.Links.Where(l => l.To == $"m{sumSolar}").OrderBy(l => l.From, StringComparer.Ordinal).ToList();
Assert.Equal([$"m{solar1}", $"m{solar2}"], incoming.Select(l => l.From).Order(StringComparer.Ordinal));
Assert.All(incoming, l =>
{
var type = await SeedTypeAsync(db, "flow_elec_d");
var solar1 = await AddMeterAsync(db, "Solar 1", type);
var solar2 = await AddMeterAsync(db, "Solar 2", type);
var sumSolar = await AddMeterAsync(db, "Sum Solar", type, MeterMode.Virtual);
var grid = await AddMeterAsync(db, "Grid", type);
var house = await AddMeterAsync(db, "House", type);
// Solar1 + Solar2 → Sum Solar ; Grid + Sum Solar → House.
db.MeterLinks.Add(new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
Assert.True(l.IsCalculated);
Assert.Equal(15, l.Value, 1);
});
Assert.DoesNotContain(graph.Links, l => l.From == $"m{solar3}");
await AddConsumptionAsync(db, solar1.Id, 15, ConsumptionKind.Generation);
await AddConsumptionAsync(db, solar2.Id, 15, ConsumptionKind.Generation);
await AddConsumptionAsync(db, grid.Id, 75);
await AddConsumptionAsync(db, house.Id, 40);
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar, an estimate.
var fromSum = graph.Links.Single(l => l.From == $"m{sumSolar}" && l.To == $"m{house}");
Assert.Equal(40.0 * 30 / 105, fromSum.Value, 1);
Assert.True(fromSum.IsEstimated);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// No spurious remainder under Solar 1/2 (their whole output is in Sum Solar).
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1}");
// Sum Solar has no readings but equals Solar 1 + Solar 2 = 30.
Assert.Equal(30, graph.Nodes.Single(n => n.MeterId == sumSolar.Id).Value, 1);
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar.
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{sumSolar.Id}" && l.To == $"m{house.Id}").Value, 1);
// No spurious remainder under Solar 1/2 (their whole output flows into Sum Solar).
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1.Id}");
}
finally
{
await ClearAsync(db);
}
// The top-level throughput is the roots: grid and the three solar meters — never the sum on top of its sources.
Assert.Equal(75 + 15 + 15 + 5, graph.Total, 1);
}
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
[Fact]
public async Task A_legacy_virtual_meter_is_its_implied_sum_until_confirmed()
{
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
var type = await TypeAsync();
var solar1 = await MeterAsync(type, "Solar 1", 15, MeterMode.GenerationCounter);
var solar2 = await MeterAsync(type, "Solar 2", 20, MeterMode.GenerationCounter);
var legacy = await VirtualAsync(type, "Legacy sum", expression: null, QuantityKind.Generation, VirtualCostRule.SourceCosts);
await LinkAsync(solar1, legacy);
await LinkAsync(solar2, legacy);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var entry = graph.MeterFor(legacy)!;
Assert.Equal(SeriesBasis.LegacyVirtual, entry.Basis);
Assert.Equal(35, entry.Value!.Value, 6);
Assert.Equal(ValueIssue.LegacyDefinition, entry.Issue);
Assert.Equal(2, graph.Links.Count(l => l.To == $"m{legacy}" && l.IsCalculated));
Assert.Contains(graph.Problems, p => p.Kind == AnalysisProblemKind.LegacyDefinition && p.MeterId == legacy);
}
[Fact]
public async Task A_virtual_meter_that_is_not_a_sum_is_only_in_the_table()
{
var type = await TypeAsync();
var a = await MeterAsync(type, "A", 100, MeterMode.GenerationCounter);
var b = await MeterAsync(type, "B", 150, MeterMode.GenerationCounter);
var difference = await VirtualAsync(type, "A minus B", $"m{a} - m{b}", QuantityKind.Generation, VirtualCostRule.None);
var house = await MeterAsync(type, "House", 40);
await LinkAsync(a, difference);
await LinkAsync(difference, house);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// Signed, from its formula — not the flow's old sum of its links (100) — and never drawn.
var entry = graph.MeterFor(difference)!;
Assert.Equal(-50, entry.Value!.Value, 6);
Assert.Equal(BucketStatus.Available, entry.Status);
Assert.False(entry.InDiagram);
Assert.DoesNotContain(graph.Nodes, n => n.MeterId == difference);
Assert.DoesNotContain(graph.Links, l => l.From == $"m{difference}" || l.To == $"m{difference}");
// Its sources and the house are still meters of the diagram.
Assert.True(graph.MeterFor(a)!.InDiagram);
Assert.Equal(40, graph.MeterFor(house)!.Value!.Value, 6);
}
[Fact]
public async Task A_link_never_carries_more_than_its_parent_measured()
{
var type = await TypeAsync();
var parent = await MeterAsync(type, "Parent", 50);
var child = await MeterAsync(type, "Child", 80);
await LinkAsync(parent, child);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var link = Assert.Single(graph.Links);
Assert.Equal(50, link.Value, 6);
Assert.True(link.IsCapped);
Assert.True(link.IsEstimated);
Assert.DoesNotContain(graph.Nodes, n => n.IsOther);
// The child's own total is untouched: capping is a drawing rule, not a correction.
Assert.Equal(80, graph.MeterFor(child)!.Value!.Value, 6);
Assert.Equal(80, graph.Nodes.Single(n => n.MeterId == child).Value, 6);
}
[Fact]
public async Task A_sub_meter_without_data_is_not_a_zero()
{
var type = await TypeAsync();
var main = await MeterAsync(type, "Main", 100);
var silent = await MeterAsync(type, "Silent", amount: null);
await LinkAsync(main, silent);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var entry = graph.MeterFor(silent)!;
Assert.Null(entry.Value);
Assert.Equal(BucketStatus.Missing, entry.Status);
// It keeps its place in the topology, drawn at zero and saying why, but nothing flows to it and the main meter's
// total does not turn into "Other": what the silent meter used is unknown, not zero.
Assert.Equal(BucketStatus.Missing, graph.Nodes.Single(n => n.MeterId == silent).Status);
Assert.Empty(graph.Links);
Assert.DoesNotContain(graph.Nodes, n => n.IsOther);
}
[Fact]
public async Task Meters_in_another_unit_or_type_stay_out_of_the_diagram()
{
var type = await TypeAsync();
var otherType = await TypeAsync("m3");
var main = await MeterAsync(type, "Main", 100);
var water = await MeterAsync(type, "Stray water meter", 7, unit: "m3");
var foreign = await MeterAsync(otherType, "Foreign", 12, unit: "m3");
await LinkAsync(main, water);
await LinkAsync(foreign, main);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// A flow never adds units: the m³ meter is in the table, with its own number and unit, but not in the diagram.
var entry = graph.MeterFor(water)!;
Assert.False(entry.InDiagram);
Assert.Equal(7, entry.Value!.Value, 6);
Assert.Equal("kWh", graph.Unit);
Assert.DoesNotContain(graph.Nodes, n => n.MeterId == water);
// A meter of another type is not part of this flow at all.
Assert.Null(graph.MeterFor(foreign));
Assert.DoesNotContain(graph.Nodes, n => n.MeterId == foreign);
Assert.Empty(graph.Links);
Assert.Equal(100, graph.Total, 6);
}
[Fact]
public async Task A_range_reaching_past_now_counts_actuals_only()
{
var type = await TypeAsync();
var main = await MeterAsync(type, "Main", 100);
// In July 2024 the reading that closes 2024 lies in the future: nothing of it is an actual yet (D-04).
var july = new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero);
var graph = await new FlowService(fx, time: new FixedTimeProvider(july)).GetFlowAsync(type, Year, NextYear);
Assert.Null(graph.MeterFor(main)!.Value);
Assert.Contains(graph.Problems, p => p.Kind == AnalysisProblemKind.RecordedAfterNow && p.MeterId == main);
Assert.Equal(0, graph.Total, 6);
}
// ------------------------------------------------------------------------------------------------ helpers
private async Task<short> TypeAsync(string unit = "kWh")
{
await using var db = fx.CreateContext();
var type = new EnergyType { Key = $"flow-{Guid.NewGuid():N}", DisplayName = "Flow test", BaseUnit = unit, DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_types.Add(type.Id);
return type.Id;
}
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta)
/// <summary>A counter installed on 1 January 2024 whose one reading at the start of 2025 books <paramref name="amount"/> over 2024; none without an amount.</summary>
private async Task<int> MeterAsync(short type, string name, double? amount, MeterMode mode = MeterMode.CumulativeCounter, string unit = "kWh")
{
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" };
await using var db = fx.CreateContext();
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, InstalledAt = Year };
db.Meters.Add(meter);
await db.SaveChangesAsync();
return meter;
_meters.Add(meter.Id);
if (amount is { } value)
{
db.Readings.Add(new Reading
{
MeterId = meter.Id,
Time = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Value = value,
Quality = ReadingQuality.Manual,
});
await db.SaveChangesAsync();
}
// Also without readings: the rollup state says the (empty) analysis data is current, not being prepared.
await new NormalizationService(db, NormalizationEngine.CreateDefault()).RecomputeMeterAsync(meter.Id, null);
await db.SaveChangesAsync();
return meter.Id;
}
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
/// <summary>A virtual meter with the given formula; without one, a legacy meter (Meta "{}").</summary>
private async Task<int> VirtualAsync(short type, string name, string? expression, QuantityKind kind, VirtualCostRule rule)
{
db.Consumption.Add(new Consumption
{
MeterId = meterId,
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
Amount = amount,
Kind = kind,
Quality = ReadingQuality.Manual,
});
await using var db = fx.CreateContext();
var meta = expression is null ? "{}" : VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, "kWh", rule));
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.Virtual, Unit = "kWh", Meta = meta };
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
return meter.Id;
}
private async Task LinkAsync(int from, int to)
{
await using var db = fx.CreateContext();
db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to });
await db.SaveChangesAsync();
}
private static async Task ClearAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
}
}
@@ -183,7 +183,8 @@ public sealed class MonthAttributionTests(TimescaleFixture fx)
try
{
var costs = await new CostService(fx, london).GetMeterCostsAsync(meterId, Utc(2026, 7, 1), Utc(2026, 10, 1));
// A clock after the range (D-01): the service stops actuals at now, and the September reading must be past.
var costs = await new CostService(fx, london, new FixedTimeProvider(Utc(2026, 10, 1))).GetMeterCostsAsync(meterId, Utc(2026, 7, 1), Utc(2026, 10, 1));
var august = Assert.Single(costs, c => c.Period == new DateOnly(2026, 8, 1));
var september = Assert.Single(costs, c => c.Period == new DateOnly(2026, 9, 1));
@@ -274,7 +275,9 @@ public sealed class MonthAttributionTests(TimescaleFixture fx)
try
{
var december = await new MeterVault.Infrastructure.Dashboard.FlowService(fx, newYork)
// Read once both readings lie in the past: actuals stop at now (D-04), and both are after today.
var later = new FixedTimeProvider(new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero));
var december = await new MeterVault.Infrastructure.Dashboard.FlowService(fx, newYork, later)
.GetFlowAsync(type.Id, new DateOnly(2026, 12, 1), new DateOnly(2027, 1, 1));
var node = Assert.Single(december.Nodes, n => n.MeterId == meter.Id);
+16 -7
View File
@@ -93,11 +93,20 @@ public sealed class LocalTimeEntryTests
}
[Theory]
[InlineData(null, 0)]
[InlineData("readings", 0)]
[InlineData("EVENTS", 2)]
[InlineData("sources", 4)]
[InlineData("nonsense", 0)]
public void Tab_keys_map_to_panel_indexes(string? tab, int expected) =>
Assert.Equal(expected, MeterLinks.TabIndex(tab));
[InlineData(null, MeterMode.CumulativeCounter, "analysis", 0)]
[InlineData("readings", MeterMode.CumulativeCounter, "readings", 1)]
[InlineData("EVENTS", MeterMode.CumulativeCounter, "events", 3)]
[InlineData("sources", MeterMode.CumulativeCounter, "sources", 5)]
[InlineData("consumption", MeterMode.CumulativeCounter, "normalized", 2)]
[InlineData("nonsense", MeterMode.CumulativeCounter, "analysis", 0)]
[InlineData("sources", MeterMode.Virtual, "calculation", 3)]
[InlineData("readings", MeterMode.Virtual, "analysis", 0)]
[InlineData("events", MeterMode.Virtual, "events", 1)]
public void Tab_keys_resolve_by_key_and_mode(string? tab, MeterMode mode, string expectedKey, int expectedPanel)
{
// Rewritten on purpose (note §10): tabs are addressed by stable keys (D-47), and which panels exist depends on
// the mode, so a key maps to a key first and only then to the position that key has on this meter.
Assert.Equal(expectedKey, MeterLinks.ResolveTab(tab, mode));
Assert.Equal(expectedPanel, MeterLinks.PanelIndex(tab, mode));
}
}
@@ -24,8 +24,9 @@ public sealed class EnumDisplayNameTests
{
foreach (var name in Enum.GetNames(type))
{
// None is the empty bitmask, deliberately rendered as nothing at all.
if (type == typeof(ReadingFlags) && name == nameof(ReadingFlags.None))
// None is the empty bitmask of a flags enum (ReadingFlags, Provenance), deliberately rendered as
// nothing at all.
if (type.IsDefined(typeof(FlagsAttribute), inherit: false) && name == "None")
{
continue;
}
@@ -93,6 +94,40 @@ public sealed class EnumDisplayNameTests
Assert.Equal("Meter swap", WithUiCulture("en", () => ReadingFlags.MeterSwap.Display()));
}
[Fact]
public void The_analysis_vocabulary_is_worded_in_both_languages()
{
Assert.Equal("Keine Daten", WithUiCulture("de", () => MeterVault.Core.Analysis.BucketStatus.Missing.Display()));
Assert.Equal("Last 12 months", WithUiCulture("en", () => MeterVault.Core.Analysis.PeriodPreset.Last12Months.Display()));
Assert.Equal("Nicht bewertet (kein Tarif)", WithUiCulture("de", () => MeterVault.Core.Analysis.Costing.CostStatus.NotPriced.Display()));
Assert.Equal("Tanks & Vorräte", WithUiCulture("de", () => Strings.Nav_Consumables));
Assert.Equal("Same period last year", WithUiCulture("en", () => new MeterVault.Core.Analysis.ComparisonRequest(MeterVault.Core.Analysis.ComparisonKind.PreviousYear).Display()));
Assert.Equal("2024", new MeterVault.Core.Analysis.ComparisonRequest(MeterVault.Core.Analysis.ComparisonKind.Year, 2024).Display());
// Provenance is a set of flags: each worded, none shown as nothing.
var provenance = MeterVault.Core.Analysis.Provenance.Measured | MeterVault.Core.Analysis.Provenance.Estimated;
Assert.Equal("Measured, Estimated", WithUiCulture("en", () => provenance.Display()));
Assert.Equal("Gemessen, Geschätzt", WithUiCulture("de", () => provenance.Display()));
Assert.Equal(string.Empty, MeterVault.Core.Analysis.Provenance.None.Display());
}
[Fact]
public void Every_meter_role_has_a_one_line_meaning_in_every_language()
{
// D-21: the editor shows a role's name and what it means, never the raw token.
foreach (var role in Enum.GetValues<MeterVault.Core.Analysis.Quantities.MeterRole>())
{
foreach (var culture in Loc.SupportedCultures)
{
var name = WithUiCulture(culture, () => role.Display());
var meaning = WithUiCulture(culture, () => role.Meaning());
Assert.False(string.IsNullOrWhiteSpace(meaning), $"{role} [{culture}]");
Assert.NotEqual(name, meaning);
Assert.DoesNotContain("_", name, StringComparison.Ordinal);
}
}
}
[Fact]
public void An_undeclared_enum_value_degrades_to_its_identifier_instead_of_throwing()
{
@@ -1,5 +1,6 @@
using System.Globalization;
using MeterVault.App;
using MeterVault.Core.Analysis;
namespace MeterVault.Integration.Tests.Localization;
@@ -12,6 +13,8 @@ namespace MeterVault.Integration.Tests.Localization;
/// </remarks>
public sealed class FormatCultureTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
[Fact]
public void Digit_grouping_follows_the_reader()
{
@@ -23,12 +26,55 @@ public sealed class FormatCultureTests
}
[Fact]
public void The_currency_symbol_stays_the_instances_own()
public void Money_is_in_the_configured_currency_written_the_readers_way()
{
// Only the grouping is localized. The figures are in the instance's configured currency, so
// an English reader must see the same money written their way not relabelled as dollars.
Assert.Equal("1.234,50 €", WithCulture("de", () => Format.Euro(1234.5)));
Assert.Equal("1,234.50 €", WithCulture("en", () => Format.Euro(1234.5)));
// Deliberately rewritten (note §10): the symbol was a hard-coded euro; it is now the configured currency's
// (D-43). Only the grouping is localized — an English reader sees the same money written their way, not
// relabelled as dollars — and the placement stays "number, space, symbol".
Assert.Equal("1.234,50 €", WithCulture("de", () => Format.Money(1234.5, "EUR")));
Assert.Equal("1,234.50 €", WithCulture("en", () => Format.Money(1234.5, "EUR")));
Assert.Equal("1,234.50 $", WithCulture("en", () => Format.Money(1234.5, "USD")));
Assert.Equal("1.234,50 £", WithCulture("de", () => Format.Money(1234.5, "gbp")));
Assert.Equal("1.234,50 CHF", WithCulture("de", () => Format.Money(1234.5, "CHF")));
Assert.Equal("12.00 SEK", WithCulture("en", () => Format.Money(12, " sek ")));
Assert.Equal("12.00 €", WithCulture("en", () => Format.Money(12, null)));
Assert.Equal("—", Format.Money(null, "EUR"));
}
[Fact]
public void The_instance_currency_comes_from_the_settings()
{
var usd = new InstanceCurrency(Microsoft.Extensions.Options.Options.Create(new MeterVault.Infrastructure.Options.MeterVaultOptions { Currency = " USD " }));
Assert.Equal("USD", usd.Code);
Assert.Equal("$", usd.Symbol);
Assert.Equal("1,234.50 $", WithCulture("en", () => usd.Format(1234.5)));
Assert.Equal("—", usd.Format((double?)null));
var blank = new InstanceCurrency(Microsoft.Extensions.Options.Options.Create(new MeterVault.Infrastructure.Options.MeterVaultOptions { Currency = " " }));
Assert.Equal("EUR", blank.Code);
}
[Fact]
public void Signed_money_shows_its_sign_and_never_a_negative_zero()
{
Assert.Equal("+12,00 €", WithCulture("de", () => Format.MoneySigned(12, "EUR")));
Assert.Equal("-3,50 €", WithCulture("de", () => Format.MoneySigned(-3.5, "EUR")));
Assert.Equal("0,00 €", WithCulture("de", () => Format.MoneySigned(0.004, "EUR")));
Assert.Equal("0,00 €", WithCulture("de", () => Format.MoneySigned(-0.004, "EUR")));
Assert.Equal("0.00 €", WithCulture("en", () => Format.Money(-0.001, "EUR")));
}
[Fact]
public void Quantities_keep_their_unit_and_say_when_they_are_unknown()
{
Assert.Equal("—", Format.Quantity(null, "kWh"));
Assert.Equal("—", Format.Quantity(double.NaN, "kWh"));
Assert.Equal("1,235 kWh", WithCulture("en", () => Format.Quantity(1234.5, "kWh")));
Assert.Equal("5,3 m³", WithCulture("de", () => Format.Quantity(5.25, "m³")));
Assert.Equal("0.12 m³", WithCulture("en", () => Format.Quantity(0.123, "m³")));
Assert.Equal("0 kWh", WithCulture("en", () => Format.Quantity(0, "kWh")));
Assert.Equal("1,234.50 L", WithCulture("en", () => Format.Quantity(1234.5, "L", 2)));
Assert.Equal("14", WithCulture("en", () => Format.Quantity(14, null)));
}
[Fact]
@@ -41,6 +87,24 @@ public sealed class FormatCultureTests
Assert.Equal("-7.2 %", WithCulture("en", () => Format.Percent(-7.2)));
}
[Fact]
public void A_change_always_states_the_difference_and_a_percentage_only_where_it_applies()
{
string Money(double v) => Format.Money(v, "EUR");
Assert.Equal("+20,00 € (+20,0 %)", WithBoth("de", () => Format.ChangeText(Change.Between(120, 100), Money)));
Assert.Equal("-25.00 € (-25.0 %)", WithBoth("en", () => Format.ChangeText(Change.Between(75, 100), Money)));
// No baseline, or a negative one: the difference stands, the percentage says it does not apply (D-08).
Assert.Equal("+50,00 € (keine Prozentangabe möglich)", WithBoth("de", () => Format.ChangeText(Change.Between(50, 0), Money)));
Assert.Equal("-2.00 € (percentage not applicable)", WithBoth("en", () => Format.ChangeText(Change.Between(-3, -1), Money)));
Assert.Equal("0.00 € (+0.0 %)", WithBoth("en", () => Format.ChangeText(Change.Between(100, 100), Money)));
// A missing value is no change at all, never "-100 %".
Assert.Equal("—", Format.ChangeText(Change.Between(null, 100), Money));
Assert.Equal("—", Format.ChangePercent(Change.Unavailable));
}
[Fact]
public void Month_labels_are_written_in_the_readers_language()
{
@@ -56,6 +120,68 @@ public sealed class FormatCultureTests
Assert.EndsWith("25", german, StringComparison.Ordinal);
}
[Fact]
public void Dates_and_ranges_name_the_year_where_it_is_needed()
{
var aug = new DateOnly(2026, 8, 19);
var sep = new DateOnly(2026, 9, 19);
Assert.Equal("Sep 19, 2026", WithCulture("en", () => Format.Date(sep)));
Assert.Equal("Sep 19", WithCulture("en", () => Format.Date(sep, includeYear: false)));
Assert.Equal("Aug 19 Sep 19, 2026", WithCulture("en", () => Format.DateRange(aug, sep)));
Assert.Equal("Dec 1, 2025 Jan 31, 2026", WithCulture("en", () => Format.DateRange(new DateOnly(2025, 12, 1), new DateOnly(2026, 1, 31))));
Assert.Equal("Aug 19 Sep 19", WithCulture("en", () => Format.DateRange(aug, sep, includeYear: false)));
Assert.Equal("Sep 19, 2026", WithCulture("en", () => Format.DateRange(sep, sep)));
// German day-first order, whatever the ICU month abbreviation.
var german = WithCulture("de", () => Format.Date(sep));
Assert.StartsWith("19.", german, StringComparison.Ordinal);
Assert.EndsWith("2026", german, StringComparison.Ordinal);
}
[Fact]
public void A_period_shows_the_dates_it_actually_covers()
{
var now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2));
var twelve = PeriodResolver.Resolve(PeriodPreset.Last12Months, null, null, now, Berlin);
Assert.Equal("Oct 1, 2025 Sep 19, 2026", WithCulture("en", () => Format.PeriodRange(twelve)));
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin);
Assert.Equal("—", Format.PeriodRange(none));
}
[Fact]
public void Bucket_labels_carry_the_year_across_years_and_real_dates_for_partial_units()
{
static AnalysisBucket Bucket(BucketSize size, DateOnly first, DateOnly end, DateOnly? nominal = null) =>
new(first, end, new DateTimeOffset(first.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), size, nominal);
var september = Bucket(BucketSize.Month, new DateOnly(2026, 9, 1), new DateOnly(2026, 10, 1));
Assert.Equal("Sep", WithCulture("en", () => Format.BucketLabel(september, includeYear: false)));
Assert.Equal("Sep 2026", WithCulture("en", () => Format.BucketLabel(september, includeYear: true)));
// The current month cut at now is still that month; a month clipped by a custom range shows its days.
var toDate = Bucket(BucketSize.Month, new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 20), new DateOnly(2026, 10, 1));
Assert.Equal("Sep 2026", WithCulture("en", () => Format.BucketLabel(toDate, includeYear: true)));
var clipped = Bucket(BucketSize.Month, new DateOnly(2026, 1, 15), new DateOnly(2026, 2, 1));
Assert.Equal("Jan 15 Jan 31, 2026", WithCulture("en", () => Format.BucketLabel(clipped, includeYear: true)));
// A week always shows its real first and last day — a partial week never looks whole.
var partialWeek = Bucket(BucketSize.Week, new DateOnly(2026, 9, 28), new DateOnly(2026, 10, 1));
Assert.Equal("Sep 28 Sep 30", WithCulture("en", () => Format.BucketLabel(partialWeek, includeYear: false)));
var yearEndWeek = Bucket(BucketSize.Week, new DateOnly(2025, 12, 29), new DateOnly(2026, 1, 5));
Assert.Equal("Dec 29, 2025 Jan 4, 2026", WithCulture("en", () => Format.BucketLabel(yearEndWeek, includeYear: true)));
Assert.Equal("Sep 19, 2026", WithCulture("en", () => Format.BucketLabel(Bucket(BucketSize.Day, new DateOnly(2026, 9, 19), new DateOnly(2026, 9, 20)), true)));
Assert.Equal("2025", WithCulture("en", () => Format.BucketLabel(Bucket(BucketSize.Year, new DateOnly(2025, 1, 1), new DateOnly(2026, 1, 1)), true)));
Assert.True(Format.SpansYears([yearEndWeek]));
Assert.False(Format.SpansYears([september, toDate]));
Assert.False(Format.SpansYears([]));
}
[Fact]
public void Direction_icons_are_language_neutral()
{
@@ -77,4 +203,19 @@ public sealed class FormatCultureTests
CultureInfo.CurrentCulture = previous;
}
}
/// <summary>Formatting and wording in one language, as a request gets them from the localization middleware.</summary>
private static T WithBoth<T>(string culture, Func<T> body)
{
var previous = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
return WithCulture(culture, body);
}
finally
{
CultureInfo.CurrentUICulture = previous;
}
}
}
@@ -0,0 +1,103 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Dashboard;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
/// <summary>
/// "Manage connections" against the database (D-30, D-25, D-28): a connection is stored only when the rules allow it —
/// never a loop, a duplicate, a link across types or into a meter itself — the check runs again inside the saving
/// transaction, and editing links never touches a virtual meter's stored calculation. A legacy virtual meter, still
/// calculated from its links, keeps them.
/// </summary>
[Collection("Timescale")]
public sealed class MeterLinkServiceTests(TimescaleFixture fx)
{
[Fact]
public async Task Connections_are_added_and_removed_but_never_close_a_loop()
{
await using var data = new TopologyData(fx);
var type = await data.TypeAsync();
var house = await data.MeterAsync(type, "House", null);
var car = await data.MeterAsync(type, "Car", null);
var wallbox = await data.MeterAsync(type, "Wallbox", null);
var service = new MeterLinkService(fx);
Assert.True((await service.AddAsync(house, car)).IsAllowed);
Assert.True((await service.AddAsync(car, wallbox)).IsAllowed);
var loop = await service.AddAsync(wallbox, house);
Assert.Equal(MeterLinkRefusal.WouldCreateCycle, loop.Refusal);
Assert.Equal([house, car, wallbox], loop.Path);
Assert.Equal(MeterLinkRefusal.AlreadyLinked, (await service.AddAsync(house, car)).Refusal);
Assert.Equal(MeterLinkRefusal.SameMeter, (await service.AddAsync(car, car)).Refusal);
Assert.Equal(MeterLinkRefusal.UnknownMeter, (await service.AddAsync(car, int.MaxValue)).Refusal);
var otherType = await data.TypeAsync("m3");
var water = await data.MeterAsync(otherType, "Water", null);
Assert.Equal(MeterLinkRefusal.OtherEnergyType, (await service.AddAsync(house, water)).Refusal);
await using (var db = fx.CreateContext())
{
var stored = await db.MeterLinks.Where(l => l.FromMeterId == house || l.FromMeterId == car || l.FromMeterId == wallbox)
.Select(l => new { l.FromMeterId, l.ToMeterId }).ToListAsync();
Assert.Equal(2, stored.Count);
Assert.DoesNotContain(stored, l => l.FromMeterId == wallbox);
}
// The topology names both ends, and a removed link can be removed only once.
var topology = await service.GetAsync(type);
Assert.Equal(3, topology.Meters.Count);
var first = Assert.Single(topology.Links, l => l.FromMeterId == house);
Assert.Equal(car, first.ToMeterId);
Assert.True((await service.RemoveAsync(first.LinkId)).IsAllowed);
Assert.Equal(MeterLinkRefusal.NotFound, (await service.RemoveAsync(first.LinkId)).Refusal);
// With the loop's first edge gone, Wallbox → House is a plain new edge.
Assert.True((await service.AddAsync(wallbox, house)).IsAllowed);
}
[Fact]
public async Task Editing_links_never_changes_a_stored_calculation_and_keeps_a_legacy_meters_links()
{
await using var data = new TopologyData(fx);
var type = await data.TypeAsync();
var solar1 = await data.MeterAsync(type, "Solar 1", null, MeterMode.GenerationCounter);
var solar2 = await data.MeterAsync(type, "Solar 2", null, MeterMode.GenerationCounter);
var definition = new VirtualDefinition($"m{solar1} + m{solar2}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var sum = await data.MeterAsync(type, "Sum", null, MeterMode.Virtual, VirtualDefinitionJson.Write("{}", definition));
var legacy = await data.MeterAsync(type, "Legacy", null, MeterMode.Virtual);
await data.LinkAsync(solar1, legacy);
var service = new MeterLinkService(fx);
string metaBefore;
await using (var db = fx.CreateContext())
{
metaBefore = await db.Meters.Where(m => m.Id == sum).Select(m => m.Meta).SingleAsync();
}
// A stored calculation: the link is topology only, and saving it leaves the formula exactly as it was (D-25).
Assert.True((await service.AddAsync(solar1, sum)).IsAllowed);
var topology = await service.GetAsync(type);
var mirror = Assert.Single(topology.Links, l => l.ToMeterId == sum);
Assert.True(topology.MirrorsCalculation(mirror));
Assert.True((await service.RemoveAsync(mirror.LinkId)).IsAllowed);
await using (var db = fx.CreateContext())
{
Assert.Equal(metaBefore, await db.Meters.Where(m => m.Id == sum).Select(m => m.Meta).SingleAsync());
}
// A legacy meter is still calculated from its links (D-28): neither a new one nor a removal is allowed here.
Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, (await service.AddAsync(solar2, legacy)).Refusal);
var kept = Assert.Single((await service.GetAsync(type)).Links, l => l.ToMeterId == legacy);
Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, (await service.RemoveAsync(kept.LinkId)).Refusal);
await using (var db = fx.CreateContext())
{
Assert.Equal(1, await db.MeterLinks.CountAsync(l => l.ToMeterId == legacy));
Assert.Equal("{}", await db.Meters.Where(m => m.Id == legacy).Select(m => m.Meta).SingleAsync());
}
}
}
@@ -0,0 +1,267 @@
using System.Globalization;
using MeterVault.App.Analysis;
using MeterVault.App.Localization;
using MeterVault.App.MeterDetails;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
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.MeterPage;
/// <summary>
/// The meter page's Analysis tab load (brief §7.2, §5.4): one committed value per request — the series from the shared
/// reader, the cost by the meter's rule, the comparison, the chart and table inputs — for a physical and a virtual
/// meter alike, on a frozen clock of 19 September 2026, 14:37 Berlin. Replaces MeterPeriodServiceTests (the service is
/// retired): the same cases, read the way the page reads them now.
/// </summary>
[Collection("Timescale")]
public sealed class MeterAnalysisLoaderTests(TimescaleFixture fx)
{
[Fact]
public async Task A_virtual_sum_of_two_generation_meters_reads_250_and_200_by_month_and_450_in_total()
{
// Brief §5.4, the worked example: A = 100/80, B = 150/120 → A+B = 250/200, 450, generation, no raw readings, no
// cost category — and the page labels it generation.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100, 80);
var b = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 150, 120);
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var view = await InEnglish(() => Loader(box).LoadAsync(sum, JanFeb(sum), Now));
Assert.Equal(sum, view.MeterId);
var series = view.Series!;
Assert.Equal(SeriesBasis.Virtual, series.Basis);
Assert.Equal(QuantityKind.Generation, series.Kind);
Assert.Equal("Generation", InEnglishSync(() => series.Kind.Display()));
Assert.Equal("kWh", series.Unit);
Assert.Equal([250d, 200d], series.Values.Select(v => v.Value!.Value));
Assert.All(series.Values, v => Assert.Equal(BucketStatus.Available, v.Status));
Assert.Equal(450d, series.Total.Value!.Value, 6);
Assert.Equal(BucketStatus.Available, series.Total.Status);
Assert.Equal(2, series.Contributions.Count);
// The chart and table say the same, with the months as their rows.
var table = Assert.Single(view.Table);
Assert.Equal(["250 kWh", "200 kWh"], table.Values.Select(v => v.Text));
Assert.Equal("450 kWh", table.Total!.Text);
Assert.Equal([250d, 200d], Assert.Single(view.Chart).Values.Select(v => v.Value!.Value));
// Generation is never billed: the meter is not costed, and the page offers no cost metric.
Assert.NotNull(view.Cost);
Assert.False(view.IsCosted);
Assert.Equal(MeterCostRule.None, view.Cost!.Meter!.Rule);
Assert.Empty(view.Metrics);
Assert.Null(table.Costs);
}
[Fact]
public async Task A_physical_meter_reads_local_months_its_comparison_and_its_cost()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2025, 7, 1));
await box.ReadingsAsync(meter,
(Midnight(2025, 7, 1), 0), (Midnight(2025, 8, 1), 0), (Midnight(2025, 9, 1), 80), (Midnight(2025, 10, 1), 150),
(Midnight(2026, 8, 1), 1000), (Midnight(2026, 9, 1), 1100), (Midnight(2026, 9, 10), 1130));
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2026, 8, 15), EventType = MeterEventType.Note, Notes = "new tenant" });
await db.SaveChangesAsync();
}
var query = AnalysisQuery.Parse("?from=2026-08-01&to=2026-08-31&bucket=month", MeterAnalysisLoader.DefaultsFor(meter));
var view = await InEnglish(() => Loader(box).LoadAsync(meter, query, Now));
var series = view.Series!;
Assert.Equal(100d, series.Total.Value!.Value, 6);
Assert.Equal(QuantityKind.Consumption, series.Kind);
// Compared with August last year (A-13 default), over what both cover.
Assert.NotNull(series.Comparison);
Assert.Equal(80d, series.Comparison!.Total.Value!.Value, 6);
Assert.Equal(20d, series.Comparison.Change.Absolute!.Value, 6);
// Its cost is its bill line at the type's price, and the change of the cost pairs complete months only.
Assert.True(view.IsCosted);
Assert.Equal(MeterCostRule.BillLine, view.Cost!.Meter!.Rule);
CostAssert.Priced(10, view.Cost.Total);
Assert.Equal(2d, view.CostChange.Change.Absolute!.Value, 6);
Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Cost], view.Metrics);
Assert.NotNull(Assert.Single(view.Table).Costs);
// The note recorded in the month is a marker under the chart.
Assert.Equal("new tenant", Assert.Single(view.Markers.Events).Notes);
// metric=cost charts the cost and its comparison instead.
var costView = await InEnglish(() => Loader(box).LoadAsync(meter, query.WithMetric(AnalysisMetric.Cost), Now));
Assert.True(costView.ShowsCost);
Assert.Equal(2, costView.Chart.Count);
Assert.True(costView.Chart.All(c => c.IsMoney));
Assert.Equal([10d], costView.Chart[0].Values.Select(v => v.Value!.Value));
Assert.Equal([8d], costView.Chart[1].Values.Select(v => v.Value!.Value));
}
[Fact]
public async Task A_reading_after_now_is_not_counted_and_the_month_is_not_a_confident_zero_before_the_data()
{
// D-04: a reading stamped on 25 September (a device clock ahead) is not an actual on the 19th.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2026, 8, 1));
await box.ReadingsAsync(
meter, (Midnight(2026, 8, 1), 0), (Midnight(2026, 9, 1), 100), (Midnight(2026, 9, 10), 130), (Midnight(2026, 9, 25), 1000));
var ytd = AnalysisQuery.Parse("?period=ytd&bucket=month", MeterAnalysisLoader.DefaultsFor(meter));
var view = await InEnglish(() => Loader(box).LoadAsync(meter, ytd, Now));
var series = view.Series!;
Assert.Equal(130d, series.Total.Value!.Value, 6);
Assert.Contains(series.RecordedAfterNow, r => r.MeterId == meter);
// The months before the install hold no data: missing, not zero.
Assert.Equal(BucketStatus.Missing, series.Values[0].Status);
Assert.Null(series.Values[0].Value);
Assert.Equal("—", view.Table[0].Values[0].Text);
}
[Fact]
public async Task A_year_of_measured_zeros_is_data_and_a_generation_meter_reports_generation()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var zeros = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2026, 5, 1));
await box.ReadingsAsync(zeros, (Midnight(2026, 5, 1), 0), (Midnight(2026, 6, 1), 0), (Midnight(2026, 7, 1), 0), (Midnight(2026, 8, 1), 0));
var pv = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", installedAt: D(2026, 6, 1));
await box.ReadingsAsync(pv, (Midnight(2026, 6, 1), 0), (Midnight(2026, 7, 1), 42));
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
var june = AnalysisQuery.Parse("?from=2026-06-01&to=2026-07-31&bucket=month&compare=none", MeterAnalysisLoader.DefaultsFor(zeros));
var zeroView = await InEnglish(() => Loader(box).LoadAsync(zeros, june, Now));
Assert.All(zeroView.Series!.Values, v => Assert.Equal((BucketStatus.Available, 0d), (v.Status, v.Value!.Value)));
Assert.Equal(0d, zeroView.Series!.Total.Value!.Value, 9);
var pvView = await InEnglish(() => Loader(box).LoadAsync(pv, june, Now));
Assert.Equal(QuantityKind.Generation, pvView.Series!.Kind);
Assert.Equal(42d, pvView.Series.Values[0].Value!.Value, 6);
Assert.False(pvView.IsCosted);
Assert.Equal(MeterNotCostedReason.Generation, pvView.Cost!.Meter!.NotCosted);
}
[Fact]
public async Task A_virtual_meter_that_cannot_be_evaluated_shows_no_values_and_is_not_costed()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
var view = await InEnglish(() => Loader(box).LoadAsync(broken, JanFeb(broken), Now));
var series = view.Series!;
Assert.Equal(BucketStatus.Invalid, series.Total.Status);
Assert.Null(series.Total.Value);
Assert.All(series.Values, v => Assert.Null(v.Value));
Assert.Equal(VirtualMeterStatus.Invalid, series.Virtual!.Status);
Assert.Contains(view.Quantities.Problems, p => p.Kind == AnalysisProblemKind.InvalidDefinition && p.MeterId == broken);
Assert.False(view.IsCosted);
}
[Fact]
public async Task A_dependency_loop_names_the_other_meter_by_its_name_never_by_its_id()
{
// Brief §4.3 / §11 "a cycle reports a named dependency error": a loop stops the evaluation, so the other end of it
// is in no contribution — the attention item and the figures still name it.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80);
var one = await box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
var two = await box.VirtualAsync(type, $"m{one}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
await using (var db = fx.CreateContext())
{
var meter = await db.Meters.FindAsync(one);
meter!.Meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{a} + m{two}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity));
await db.SaveChangesAsync();
}
string twoName;
await using (var db = fx.CreateContext())
{
twoName = (await db.Meters.FindAsync(two))!.Name;
}
var view = await InEnglish(() => Loader(box).LoadAsync(one, JanFeb(one), Now));
Assert.Equal(BucketStatus.Invalid, view.Series!.Total.Status);
Assert.Equal(twoName, view.MeterNames[two]);
var items = InEnglishSync(() => AttentionItems.Build(
view.Quantities.Problems.Concat(view.Cost?.QuantityProblems ?? []), view.Cost?.Attention, view.AttentionNames, view.Query));
var loop = Assert.Single(items, i => i.Text.Contains(" → ", StringComparison.Ordinal));
Assert.Contains(twoName, loop.Text, StringComparison.Ordinal);
Assert.DoesNotContain("#" + two.ToString(CultureInfo.InvariantCulture), loop.Text, StringComparison.Ordinal);
Assert.DoesNotContain("Meter #", loop.Text, StringComparison.Ordinal);
// The figures that name a culprit name it too.
var details = view.Table.Single().Values.Select(v => v.Status.Detail).Append(view.Table.Single().Total!.Status.Detail).OfType<string>().ToList();
Assert.DoesNotContain(details, d => d.Contains('#', StringComparison.Ordinal));
}
[Fact]
public async Task A_meter_that_is_being_rebuilt_is_pending_and_nothing_is_priced()
{
// D-16: hand-inserted consumption without rollups reads as "being prepared", never as no data.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.DirectDelta, "kWh");
await using (var db = fx.CreateContext())
{
db.Consumption.Add(new Consumption { MeterId = meter, Time = Midnight(2026, 2, 1), Amount = 5, Kind = ConsumptionKind.Consumption, Quality = ReadingQuality.Measured });
await db.SaveChangesAsync();
}
var view = await InEnglish(() => Loader(box).LoadAsync(meter, JanFeb(meter), Now));
Assert.True(view.Series!.IsPending);
Assert.Null(view.Cost);
}
private static AnalysisQuery JanFeb(int meterId) =>
AnalysisQuery.Parse("?from=2026-01-01&to=2026-02-28&bucket=month&compare=none", MeterAnalysisLoader.DefaultsFor(meterId));
private MeterAnalysisLoader Loader(CostSandbox box)
{
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);
_ = box;
return new MeterAnalysisLoader(new AnalysisPeriods(reader, costs), reader, costs, new MeterDetailService(fx, options, reader));
}
/// <summary>Runs a load with English as the formatting and UI culture (the chart and table text is built inside it).</summary>
private static async Task<T> InEnglish<T>(Func<Task<T>> load)
{
var format = CultureInfo.CurrentCulture;
var ui = CultureInfo.CurrentUICulture;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en");
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en");
try
{
return await load();
}
finally
{
CultureInfo.CurrentCulture = format;
CultureInfo.CurrentUICulture = ui;
}
}
private static T InEnglishSync<T>(Func<T> body) => Analysis.AnalysisUiTestData.In("en", body);
}
@@ -0,0 +1,286 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Options;
using MeterVault.Integration.Tests.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.MeterPage;
/// <summary>
/// The meter page's read model (brief §7.2, D-50): record tabs paged server-side, newest first, keyset-ordered on each
/// table's key and filtered by a half-open range; the manual-entry dialog's own context for the entered instant (the
/// neighbours the ingestion guard reads); markers inside a range; applicable tariffs; and a virtual meter's calculation.
/// </summary>
[Collection("Timescale")]
public sealed class MeterDetailServiceTests(TimescaleFixture fx)
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Readings_page_newest_first_by_keyset_and_filter_by_a_half_open_range()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await InsertReadingsAsync(meter, 250);
var service = Service();
var first = await service.GetReadingsAsync(meter, RecordRange.All);
var second = await service.GetReadingsAsync(meter, RecordRange.All, first.Next);
var third = await service.GetReadingsAsync(meter, RecordRange.All, second.Next);
Assert.Equal((100, 100, 50), (first.Rows.Count, second.Rows.Count, third.Rows.Count));
Assert.All(new[] { first, second, third }, page => Assert.Equal(250, page.Total));
Assert.NotNull(first.Next);
Assert.NotNull(second.Next);
Assert.Null(third.Next);
var all = first.Rows.Concat(second.Rows).Concat(third.Rows).Select(r => r.Time).ToList();
Assert.Equal(250, all.Distinct().Count());
Assert.Equal(all.OrderDescending(), all);
Assert.Equal(Start.AddHours(249), all[0]);
// Keyset, not offset: a reading arriving at the top does not shift the older pages.
await InsertReadingsAsync(meter, 1, fromHour: 1000);
var again = await service.GetReadingsAsync(meter, RecordRange.All, first.Next);
Assert.Equal(second.Rows, again.Rows);
Assert.Equal(251, again.Total);
// [from, to): the start is included, the end is not.
var range = new RecordRange(Start.AddHours(10), Start.AddHours(20));
var filtered = await service.GetReadingsAsync(meter, range);
Assert.Equal(10, filtered.Total);
Assert.Equal(Start.AddHours(19), filtered.Rows[0].Time);
Assert.Equal(Start.AddHours(10), filtered.Rows[^1].Time);
Assert.Null(filtered.Next);
}
[Fact]
public async Task Normalized_rows_break_ties_on_their_kind_across_a_page_boundary()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.DirectDelta, "kWh");
// One row at the newest instant, then 51 instants with both kinds: the 100th and 101st rows share an instant.
await using (var db = fx.CreateContext())
{
db.Consumption.Add(Row(meter, 51, ConsumptionKind.Consumption));
for (var i = 0; i <= 50; i++)
{
db.Consumption.Add(Row(meter, i, ConsumptionKind.Consumption));
db.Consumption.Add(Row(meter, i, ConsumptionKind.Generation));
}
await db.SaveChangesAsync();
}
var service = Service();
var first = await service.GetConsumptionAsync(meter, RecordRange.All);
var second = await service.GetConsumptionAsync(meter, RecordRange.All, first.Next);
Assert.Equal(103, first.Total);
Assert.Equal(100, first.Rows.Count);
Assert.Equal((Start.AddHours(1), ConsumptionKind.Generation), (first.Rows[^1].Time, first.Rows[^1].Kind));
Assert.Equal(3, second.Rows.Count);
Assert.Equal((Start.AddHours(1), ConsumptionKind.Consumption), (second.Rows[0].Time, second.Rows[0].Kind));
Assert.Null(second.Next);
Assert.Equal(103, first.Rows.Concat(second.Rows).Select(r => (r.Time, r.Kind)).Distinct().Count());
}
[Fact]
public async Task Events_page_on_their_instant_then_their_id()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await using (var db = fx.CreateContext())
{
// All at one instant: only the id orders them.
for (var i = 0; i < 101; i++)
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Start, EventType = MeterEventType.Note, Notes = $"note {i}" });
}
await db.SaveChangesAsync();
}
var service = Service();
var first = await service.GetEventsAsync(meter, RecordRange.All);
var second = await service.GetEventsAsync(meter, RecordRange.All, first.Next);
Assert.Equal((100, 1, 101), (first.Rows.Count, second.Rows.Count, first.Total));
var ids = first.Rows.Concat(second.Rows).Select(e => e.Id).ToList();
Assert.Equal(101, ids.Distinct().Count());
Assert.Equal(ids.OrderDescending(), ids);
}
[Fact]
public async Task The_entry_context_judges_a_reading_by_the_neighbours_the_guard_reads()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
// Imported month rows: "July 2026" (400) and "August 2026" (500), stamped on the 1st (UTC, as the importer writes
// them), describing the month's end.
await using (var db = fx.CreateContext())
{
db.Readings.Add(new Reading { MeterId = meter, Time = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), Value = 400, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
db.Readings.Add(new Reading { MeterId = meter, Time = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), Value = 500, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
await db.SaveChangesAsync();
}
var service = Service();
var at = Midnight(2026, 8, 20);
var context = await service.GetReadingEntryContextAsync(meter, at);
// The latest reading by stamp is August's (500), but on the timeline a reading on 20 August comes after July's end
// and before August's: the dialog judges it against 400, exactly like the guard — the old page, holding only
// the latest row, would have called 450 a decrease.
Assert.NotNull(context);
Assert.Equal(500, context!.Latest!.Value);
Assert.Equal(400, context.Previous!.Value);
Assert.True(context.Monotonic);
Assert.False(context.BoundaryExplainsDecrease);
Assert.Null(context.AtTime);
await using (var db = fx.CreateContext())
{
var ingestion = new IngestionService(db, Normalization(db));
Assert.Equal(IngestionOutcome.RejectedDecrease, await ingestion.IngestByMeterAsync(meter, at, 350, renormalize: false, quality: ReadingQuality.Manual));
Assert.Equal(IngestionOutcome.Written, await ingestion.IngestByMeterAsync(meter, at, 450, renormalize: false, quality: ReadingQuality.Manual));
}
// The reading at that instant is now what a save would replace.
var replacing = await service.GetReadingEntryContextAsync(meter, at);
Assert.Equal(450, replacing!.AtTime!.Value);
Assert.False(replacing.AtTime.IsRegisterStart);
// A swap after the previous reading explains a lower value.
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2026, 9, 5), EventType = MeterEventType.MeterSwap, PrevValue = 520, NewValue = 0 });
await db.SaveChangesAsync();
}
var afterSwap = await service.GetReadingEntryContextAsync(meter, Midnight(2026, 9, 10));
Assert.True(afterSwap!.BoundaryExplainsDecrease);
Assert.Null(await service.GetReadingEntryContextAsync(int.MaxValue, at));
}
[Fact]
public async Task The_identity_read_carries_the_register_span_and_the_normalized_unit()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", installedAt: D(2026, 1, 1));
await box.ReadingsAsync(meter, (Midnight(2026, 1, 1), 10), (Midnight(2026, 2, 1), 110));
var service = Service();
var detail = await service.GetAsync(meter);
Assert.NotNull(detail);
Assert.True(detail!.HasReadings);
Assert.False(detail.HasEvents);
Assert.Equal((10d, 110d), (detail.FirstReading!.Value, detail.LastReading!.Value));
Assert.Equal(QuantityKind.Generation, detail.Kind);
Assert.Equal("kWh", detail.NormalizedUnit);
Assert.Equal(D(2026, 1, 1), detail.InstalledAt);
Assert.Null(await service.GetAsync(int.MaxValue));
}
[Fact]
public async Task Markers_hold_the_events_and_tariff_changes_inside_the_range_only()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
var other = await box.TypeAsync();
await box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
await box.TypePriceAsync(type, 0.32, D(2025, 7, 1));
await box.MeterPriceAsync(meter, 0.25, D(2025, 3, 1));
await box.TypePriceAsync(other, 0.99, D(2025, 3, 1));
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 2, 10), EventType = MeterEventType.Note, Notes = "before" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "inside" });
await db.SaveChangesAsync();
}
var service = Service();
var markers = await service.GetMarkersAsync(
meter, new RecordRange(Midnight(2025, 3, 1), Midnight(2025, 7, 1)), D(2025, 3, 1), D(2025, 6, 30));
Assert.Equal("inside", Assert.Single(markers.Events).Notes);
Assert.False(markers.MoreEvents);
var change = Assert.Single(markers.TariffChanges);
Assert.Equal((TariffScope.Meter, 0.25), (change.Scope, change.Value));
// The tariff list: the meter's own and its type's (and any global), never another type's.
var tariffs = await service.GetTariffsAsync(meter);
Assert.Contains(tariffs, t => t.Scope == TariffScope.Meter && t.ScopeId == meter);
Assert.Equal(2, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
Assert.DoesNotContain(tariffs, t => t.Scope == TariffScope.EnergyType && t.ScopeId == other);
}
[Fact]
public async Task A_virtual_meters_calculation_names_its_sources_and_its_problems()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", name: "PV Ost");
var b = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", name: "PV West");
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var service = Service();
var calculation = await service.GetCalculationAsync(sum);
Assert.NotNull(calculation);
Assert.Equal(VirtualMeterStatus.Valid, calculation!.Status);
Assert.Equal($"m{a} + m{b}", calculation.Expression);
Assert.Equal((QuantityKind.Generation, "kWh"), (calculation.Kind, calculation.Unit));
Assert.Equal(["PV Ost", "PV West"], calculation.Sources.Select(s => s.Name));
Assert.All(calculation.Sources, s => Assert.True(s.Exists));
Assert.Equal("PV West", calculation.NameOf(b));
Assert.Empty(calculation.Problems);
var invalid = await service.GetCalculationAsync(broken);
Assert.Equal(VirtualMeterStatus.Invalid, invalid!.Status);
var problem = Assert.Single(invalid.Problems);
Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind);
Assert.Equal([int.MaxValue], problem.MeterIds);
Assert.Contains(invalid.Sources, s => s.MeterId == int.MaxValue && !s.Exists);
// A physical meter has no calculation.
Assert.Null(await service.GetCalculationAsync(a));
}
private MeterDetailService Service() =>
new(fx, Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }));
private static Consumption Row(int meter, int hour, ConsumptionKind kind) => new()
{
MeterId = meter,
Time = Start.AddHours(hour),
Amount = 1,
Kind = kind,
Quality = ReadingQuality.Measured,
};
private async Task InsertReadingsAsync(int meter, int count, int fromHour = 0)
{
await using var db = fx.CreateContext();
for (var i = 0; i < count; i++)
{
db.Readings.Add(new Reading { MeterId = meter, Time = Start.AddHours(fromHour + i), Value = fromHour + i, Quality = ReadingQuality.Measured });
}
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,281 @@
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.App.MeterDetails;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Dashboard;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.MeterPage;
/// <summary>
/// The meter page's pure rules (brief §7.2, D-09, D-46, D-47, D-50): which tab a link opens, how the manual-entry
/// dialog judges an entry against its own context, the keyset pager, the record tabs' date filter and the projection's
/// suppression rules. No database.
/// </summary>
public sealed class MeterPageLogicTests
{
// ---------------------------------------------------------------------------------------------------- tab keys
[Theory]
[InlineData("analysis", MeterMode.CumulativeCounter, "analysis")]
[InlineData("readings", MeterMode.CumulativeCounter, "readings")]
[InlineData("consumption", MeterMode.CumulativeCounter, "normalized")]
[InlineData("Consumption", MeterMode.ConsumableBalance, "normalized")]
[InlineData("events", MeterMode.ConsumableBalance, "events")]
[InlineData("tariffs", MeterMode.GenerationCounter, "tariffs")]
[InlineData("sources", MeterMode.RuntimeCounter, "sources")]
[InlineData("calculation", MeterMode.CumulativeCounter, "analysis")]
[InlineData("sources", MeterMode.Virtual, "calculation")]
[InlineData("readings", MeterMode.Virtual, "analysis")]
[InlineData("normalized", MeterMode.Virtual, "analysis")]
[InlineData("consumption", MeterMode.Virtual, "analysis")]
[InlineData(" EVENTS ", MeterMode.Virtual, "events")]
[InlineData(null, MeterMode.Virtual, "analysis")]
[InlineData("", MeterMode.DirectDelta, "analysis")]
public void Every_old_and_new_tab_link_opens_a_tab_the_meter_shows(string? requested, MeterMode mode, string expected)
{
Assert.Equal(expected, MeterLinks.ResolveTab(requested, mode));
Assert.Equal(MeterLinks.VisibleTabs(mode).ToList().IndexOf(expected), MeterLinks.PanelIndex(requested, mode));
}
[Fact]
public void The_links_other_pages_write_open_the_tab_they_name()
{
// Quick entry, event links and the connector detour still land on the tab they meant, with their action.
Assert.Equal("/meters/7?tab=readings&action=reading", MeterLinks.QuickEntry(7, MeterMode.CumulativeCounter));
Assert.Equal("/meters/7?tab=events&action=tank-level", MeterLinks.QuickEntry(7, MeterMode.ConsumableBalance));
Assert.Null(MeterLinks.QuickEntry(9, MeterMode.Virtual));
Assert.Equal("/meters/7?tab=events&action=swap", MeterLinks.Event(7, MeterEventType.MeterSwap));
Assert.StartsWith("/meters/7?tab=sources&action=source", MeterLinks.Source(7, 3, SourceType.Mqtt, 12), StringComparison.Ordinal);
Assert.Equal(MeterLinks.TabSources, MeterLinks.ResolveTab("sources", MeterMode.CumulativeCounter));
Assert.Equal(MeterLinks.TabEvents, MeterLinks.ResolveTab("events", MeterMode.ConsumableBalance));
// A drill-down into records keeps the range it came from, on the Normalized data tab.
var query = AnalysisQuery.Default(MeterAnalysisLoader.DefaultsFor(7));
var bucket = Buckets(D(2025, 3, 1), D(2025, 3, 31))[0];
Assert.Equal("/meters/7?tab=normalized&from=2025-03-01&to=2025-03-31", AnalysisNavigation.NormalizedData(7, query, bucket));
}
[Fact]
public void The_meter_page_reads_its_query_with_the_meter_as_scope()
{
var defaults = MeterAnalysisLoader.DefaultsFor(7);
var plain = AnalysisQuery.Parse("/meters/7?tab=events", defaults);
Assert.Equal(QueryScope.ForMeter(7), plain.Scope);
Assert.Equal(PeriodPreset.Last12Months, plain.Period);
Assert.Equal(ComparisonKind.PreviousYear, plain.Comparison.Kind);
// A stray scope in the address cannot make the page analyse something else.
var stray = AnalysisQuery.Parse("/meters/7?scope=type&id=3&period=ytd", defaults);
var forced = MeterAnalysisLoader.ForMeter(stray, 7);
Assert.Equal(QueryScope.ForMeter(7), forced.Scope);
Assert.Equal(PeriodPreset.YearToDate, forced.Period);
// The tab and the action are not part of the analysis state: changing them is not a new analysis (D-46).
Assert.Equal(
AnalysisQuery.Parse("/meters/7?tab=analysis&period=ytd", defaults),
AnalysisQuery.Parse("/meters/7?tab=events&action=swap&period=ytd", defaults));
}
// ------------------------------------------------------------------------------------------ manual-entry verdict
private static readonly DateTimeOffset T = new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero);
private static ReadingEntryContext Context(
double? previous = 1000, double? latest = 1000, DateTimeOffset? latestAt = null, bool monotonic = true,
bool boundary = false, ExistingReading? atTime = null, DateTimeOffset? at = null) =>
new(
MeterId: 1,
At: at ?? T,
Unit: "kWh",
InitialBaseline: 0,
Monotonic: monotonic,
Latest: latest is { } l ? new RegisterPoint(latestAt ?? T.AddDays(-1), l) : null,
Previous: previous is { } p ? new RegisterPoint(T.AddDays(-1), p) : null,
BoundaryExplainsDecrease: boundary,
AtTime: atTime);
[Fact]
public void A_lower_value_on_a_register_is_flagged_unless_a_swap_or_reset_explains_it()
{
var verdict = ReadingEntryVerdict.Of(Context(), 999, T, T);
Assert.True(verdict.WouldBeRejected);
Assert.Null(verdict.ChangeSincePrevious);
Assert.Equal(1000, verdict.Previous!.Value);
Assert.False(ReadingEntryVerdict.Of(Context(boundary: true), 999, T, T).WouldBeRejected);
Assert.False(ReadingEntryVerdict.Of(Context(monotonic: false), 999, T, T).WouldBeRejected);
var up = ReadingEntryVerdict.Of(Context(), 1012.5, T, T);
Assert.False(up.WouldBeRejected);
Assert.Equal(12.5, up.ChangeSincePrevious!.Value, 9);
}
[Fact]
public void A_backdated_entry_is_judged_against_the_reading_before_it_not_the_latest()
{
// The old page only held the latest reading and gave a backdated entry no verdict; the dialog's own context
// (D-50) knows the reading before the entered time, as the ingestion guard does.
var context = Context(previous: 500, latest: 1000, latestAt: T.AddDays(10));
var lower = ReadingEntryVerdict.Of(context, 450, T, T.AddDays(11));
Assert.True(lower.IsBackdated);
Assert.True(lower.WouldBeRejected);
var between = ReadingEntryVerdict.Of(context, 700, T, T.AddDays(11));
Assert.True(between.IsBackdated);
Assert.False(between.WouldBeRejected);
Assert.Equal(200, between.ChangeSincePrevious!.Value, 9);
}
[Fact]
public void A_context_for_another_instant_gives_no_verdict_rather_than_a_wrong_one()
{
// The pickers moved and the new context is still being read: nothing is judged against the old instant.
var verdict = ReadingEntryVerdict.Of(Context(), 1, T.AddHours(1), T.AddHours(1));
Assert.False(verdict.WouldBeRejected);
Assert.False(verdict.ReplacesReading);
Assert.Null(verdict.ChangeSincePrevious);
Assert.False(ReadingEntryVerdict.Of(null, 1, T, T).WouldBeRejected);
Assert.True(ReadingEntryVerdict.Of(null, 1, T.AddMinutes(2), T).IsFuture);
Assert.False(ReadingEntryVerdict.Of(null, 1, T.AddSeconds(30), T).IsFuture);
}
[Fact]
public void Saving_over_a_stored_reading_says_whether_it_is_a_swap_start()
{
var plain = ReadingEntryVerdict.Of(Context(atTime: new ExistingReading(T, 1000, ReadingQuality.Manual, ReadingFlags.None)), 1001, T, T);
Assert.True(plain.ReplacesReading);
Assert.False(plain.ReplacesRegisterStart);
var start = ReadingEntryVerdict.Of(Context(atTime: new ExistingReading(T, 0, ReadingQuality.Manual, ReadingFlags.MeterSwap)), 3, T, T);
Assert.True(start.ReplacesReading);
Assert.True(start.ReplacesRegisterStart);
}
// ------------------------------------------------------------------------------------------------- record pager
[Fact]
public void The_pager_walks_keyset_pages_and_back()
{
var pager = new RecordPager();
Assert.Null(pager.Current);
Assert.False(pager.CanGoNewer);
Assert.Equal(1, pager.FirstRow);
var first = new RecordCursor(T, 0);
var second = new RecordCursor(T.AddDays(-5), 3);
pager.Older(first);
pager.Older(second);
Assert.Equal(second, pager.Current);
Assert.Equal(2, pager.PageIndex);
Assert.Equal((2 * MeterDetailService.PageSize) + 1, pager.FirstRow);
pager.Newer();
Assert.Equal(first, pager.Current);
pager.Newer();
pager.Newer();
Assert.Null(pager.Current);
Assert.Equal(0, pager.PageIndex);
pager.Older(first);
pager.Reset();
Assert.Null(pager.Current);
}
// ---------------------------------------------------------------------------------------------- record filter
[Fact]
public void Record_tabs_filter_by_the_page_period_and_show_everything_for_all_history()
{
var custom = Range(D(2025, 3, 1), D(2025, 3, 31));
var range = MeterRecordRange.Of(custom);
Assert.Equal(new DateTimeOffset(2025, 2, 28, 23, 0, 0, TimeSpan.Zero), range.From);
Assert.Equal(new DateTimeOffset(2025, 3, 31, 22, 0, 0, TimeSpan.Zero), range.To); // CEST from 30 March
// Month to date: the rows of the whole month, so rows stamped later this month (after now) are listed too.
var mtd = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, Berlin);
var month = MeterRecordRange.Of(mtd);
Assert.Equal(new DateTimeOffset(2026, 8, 31, 22, 0, 0, TimeSpan.Zero), month.From);
Assert.Equal(new DateTimeOffset(2026, 9, 30, 22, 0, 0, TimeSpan.Zero), month.To);
var all = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, Now, Berlin, D(2019, 1, 1), D(2026, 5, 31));
Assert.Same(RecordRange.All, MeterRecordRange.Of(all));
Assert.False(RecordRange.All.IsBounded);
}
[Fact]
public void Record_tabs_show_the_dates_they_list_and_mark_rows_after_now() => In("en", () =>
{
// D-04: the record tabs list the rows of the whole named range, rows dated after now included; the dates on screen
// must say so (the whole month, not "up to today"), and each such row carries a mark.
var mtd = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, Berlin);
Assert.Equal("Sep 1 Sep 19, 2026", Format.PeriodRange(mtd));
Assert.Equal("Sep 1 Sep 30, 2026", MeterRecordRange.Text(mtd));
var custom = PeriodResolver.Resolve(PeriodPreset.Custom, D(2026, 9, 19), D(2026, 9, 25), Now, Berlin);
Assert.Equal("Sep 19 Sep 25, 2026", MeterRecordRange.Text(custom));
Assert.True(MeterRecordRange.IsAfterNow(new DateTimeOffset(2026, 9, 25, 4, 0, 0, TimeSpan.Zero), mtd));
Assert.True(MeterRecordRange.IsAfterNow(Now.AddMinutes(1), mtd));
Assert.False(MeterRecordRange.IsAfterNow(Now, mtd));
Assert.False(MeterRecordRange.IsAfterNow(new DateTimeOffset(2026, 9, 1, 0, 0, 0, TimeSpan.Zero), mtd));
});
// --------------------------------------------------------------------------------------------------- projection
private static AnalysisSeries MonthSeries(double total, ResolutionClass? resolution, DateTimeOffset coveredFrom, DateTimeOffset coveredTo,
Provenance provenance = Provenance.Measured) =>
Series(1, "Haus", [BucketValue.Available(total, provenance)], new BucketValue(total, BucketStatus.Partial, provenance)) with
{
Resolution = resolution,
Availability = AvailableRange.Of(coveredFrom, coveredTo, Berlin),
};
[Fact]
public void A_live_month_to_date_projects_the_covered_rate_over_the_rest_of_the_month()
{
var mtd = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, Berlin);
var series = MonthSeries(180, ResolutionClass.Day, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), Now.AddHours(-2));
var projection = MeterProjection.For(series, mtd);
Assert.NotNull(projection);
var covered = (Now.AddHours(-2) - mtd.From).TotalDays;
var remaining = (mtd.NominalEnd()!.Value - Now).TotalDays;
Assert.Equal(180 + (180 / covered * remaining), projection!.Value, 6);
Assert.Equal((int)Math.Round(covered), projection.Days);
Assert.Equal("kWh", projection.Unit);
}
[Fact]
public void A_projection_is_suppressed_for_old_coarse_short_or_opening_balance_data()
{
var mtd = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, Berlin);
var start = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Monthly data cannot project a month.
Assert.Null(MeterProjection.For(MonthSeries(180, ResolutionClass.Month, start, Now), mtd));
// Data that stopped days ago is not live: two daily intervals is the limit.
Assert.Null(MeterProjection.For(MonthSeries(180, ResolutionClass.Day, start, Now.AddDays(-3)), mtd));
// Less than a week covered this month.
Assert.Null(MeterProjection.For(MonthSeries(180, ResolutionClass.Day, mtd.From.AddDays(12), Now), mtd));
// An opening balance of unknown start is never extrapolated (D-14).
Assert.Null(MeterProjection.For(MonthSeries(180, ResolutionClass.Day, start, Now, Provenance.OpeningBalance), mtd));
// Only month and year to date are projected.
var twelve = PeriodResolver.Resolve(PeriodPreset.Last12Months, null, null, Now, Berlin);
Assert.Null(MeterProjection.For(MonthSeries(180, ResolutionClass.Day, start, Now), twelve));
// A year to date may use monthly data, as long as it is current.
var ytd = PeriodResolver.Resolve(PeriodPreset.YearToDate, null, null, Now, Berlin);
Assert.NotNull(MeterProjection.For(MonthSeries(1800, ResolutionClass.Month, start, Now.AddDays(-10)), ytd));
Assert.Null(MeterProjection.For(MonthSeries(1800, ResolutionClass.Month, start, Now.AddDays(-100)), ytd));
}
}
@@ -0,0 +1,63 @@
using MeterVault.App;
using MeterVault.Core.Domain;
using MeterVault.Integration.Tests.Costing;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests.MeterPage;
/// <summary>
/// Brief §3.2 "Change the source/connector: Meter → Sources → Edit connection": the connector a source uses opens for
/// editing from the meter's Sources tab, through the connector page's way back to the meter (the draft detour).
/// </summary>
[Collection("Timescale")]
public sealed class MeterSourcesRenderTests(TimescaleFixture fx)
{
[Fact]
public async Task The_sources_tab_links_the_connector_in_use_for_editing()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("m³");
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "m³", name: $"Wasser {Guid.NewGuid():N}");
int endpointId;
int sourceId;
await using (var db = fx.CreateContext())
{
var endpoint = new IngestionEndpoint
{
Type = EndpointType.HomeAssistant,
Name = $"HA {Guid.NewGuid():N}",
Config = """{"baseUrl":"http://ha.local:8123","tokenEnv":"HA_TOKEN"}""",
};
db.IngestionEndpoints.Add(endpoint);
await db.SaveChangesAsync();
endpointId = endpoint.Id;
var source = new MeterSource
{
MeterId = meter,
SourceType = SourceType.HomeAssistant,
EndpointId = endpointId,
Config = """{"entityId":"sensor.water_total"}""",
};
db.MeterSources.Add(source);
await db.SaveChangesAsync();
sourceId = source.Id;
}
try
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
var html = System.Net.WebUtility.HtmlDecode(
await client.GetStringAsync(new Uri($"/meters/{meter}?tab=sources", UriKind.Relative)));
var edit = MeterLinks.EditConnector(meter, sourceId, SourceType.HomeAssistant, endpointId);
Assert.Contains($"href=\"{edit}\"", html, StringComparison.Ordinal);
}
finally
{
await using var db = fx.CreateContext();
await db.MeterSources.Where(s => s.Id == sourceId).ExecuteDeleteAsync();
await db.IngestionEndpoints.Where(e => e.Id == endpointId).ExecuteDeleteAsync();
}
}
}
@@ -1,158 +0,0 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The meter-detail headline numbers: month/year totals bucketed in the instance timezone, and the
/// generation-vs-consumption split that decides which of the two a meter reports.
/// </summary>
[Collection("Timescale")]
public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
{
[Fact]
public async Task Buckets_by_local_month_and_compares_with_the_previous_one()
{
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var thisMonth = new DateOnly(today.Year, today.Month, 1);
var lastMonth = thisMonth.AddMonths(-1);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), 30);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, lastMonth.AddDays(3), 100);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Equal(ConsumptionKind.Consumption, view!.Kind);
Assert.Equal(30d, view.MonthToDate, 3);
Assert.Equal(100d, view.LastMonth, 3);
Assert.Equal(130d, view.YearToDate, 3);
// Projection scales the partial month up, so it must be at least what has already happened.
Assert.True(view.MonthProjected >= view.MonthToDate);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_generation_counter_reports_generation_not_consumption()
{
// Regression: a PV meter showed "0 kWh consumption", which is true and useless.
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.GenerationCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
await AddConsumptionAsync(
db, meterId, ConsumptionKind.Generation, new DateOnly(today.Year, today.Month, 1).AddDays(1), 42);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Equal(ConsumptionKind.Generation, view!.Kind);
Assert.Equal(42d, view.MonthToDate, 3);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_meter_with_no_consumption_yields_an_empty_history_rather_than_a_flat_line()
{
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.False(view!.HasHistory);
Assert.Empty(view.Last12Months);
Assert.Null(view.MonthChange); // no previous month to divide by
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_virtual_meter_reports_nothing_rather_than_a_confident_zero()
{
// Virtual meters evaluate on read and only materialize when a cost category references them
// (SDD §14.1). Summing `consumption` would render four zero tiles for a working meter.
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.Virtual);
Assert.Null(await NewService().GetAsync(meterId));
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_negative_previous_period_reports_no_basis_rather_than_an_inverted_percentage()
{
// Net export: -100 -> -150 is half again as much exported, but dividing by a negative
// baseline would render it "+50%", which reads as more consumption.
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var thisMonth = new DateOnly(today.Year, today.Month, 1);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), -150);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddMonths(-1).AddDays(3), -100);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Null(view!.MonthChange);
await CleanupAsync(db, meterId);
}
private MeterPeriodService NewService()
{
var options = Microsoft.Extensions.Options.Options.Create(
new MeterVaultOptions { TimeZone = "Europe/Berlin", Currency = "EUR" });
return new MeterPeriodService(fx, new CostService(fx), options);
}
private static async Task<int> SetupAsync(MeterVaultDbContext db, MeterMode mode)
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter
{
Name = $"period-{Guid.NewGuid():N}",
EnergyTypeId = type.Id,
Mode = mode,
Unit = "kWh",
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
return meter.Id;
}
private static async Task AddConsumptionAsync(
MeterVaultDbContext db, int meterId, ConsumptionKind kind, DateOnly day, double amount)
{
db.Consumption.Add(new Consumption
{
MeterId = meterId,
// Midday local, so the row cannot drift into an adjacent month through the UTC offset.
Time = new DateTimeOffset(day.Year, day.Month, day.Day, 12, 0, 0, TimeSpan.Zero),
Kind = kind,
Amount = amount,
Quality = ReadingQuality.Measured,
});
await db.SaveChangesAsync();
}
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
{
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}
@@ -0,0 +1,241 @@
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using MeterVault.Integration.Tests.Costing;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Overview;
/// <summary>
/// The Overview's read model (<see cref="DashboardService.GetOverviewAsync"/>, brief §7.1) on the frozen clock of
/// <see cref="CostSandbox.Now"/>: the seeded instance's previous year is the sheet's bill with a composition and change
/// rows that add up to it and per-type measures in their own units; this month to date has no data and points at the
/// latest month; a year to date is compared only over what both years cover (D-07); an instance with nothing but manual
/// costs agrees across the bill, the chart buckets, the composition, the rows and the latest month.
/// </summary>
/// <remarks>The Overview reads the whole instance, so every test starts from — and leaves — an instance without meters.</remarks>
[Collection("Timescale")]
public sealed class OverviewDataTests(TimescaleFixture fx) : IAsyncLifetime
{
private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear);
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task The_seeded_previous_year_is_the_sheet_s_bill_and_every_panel_adds_up_to_it()
{
await LoadReferenceDataAsync();
await using var db = fx.CreateContext();
var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Name, m => m.Id);
var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => t.Key, t => (int)t.Id);
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.PreviousYear), BucketSize.Auto, PreviousYear);
// D-44: the 2025 bill is the sheet's Jahreskosten (7,907.64 €), priced (the tank is "not priced", an attention item).
Assert.False(overview.HasNoData);
Assert.Equal(BucketSize.Month, overview.Plan.Size);
Assert.Equal(12, overview.Plan.Buckets.Count);
Assert.Equal(CostStatus.Priced, overview.Cost.Total.Status);
Assert.InRange(overview.Cost.Total.Cost!.Value, 7907.64 - 0.02, 7907.64 + 0.02);
Assert.Equal(overview.Cost.Total.Cost!.Value, overview.Cost.Buckets.Sum(b => b.Cost ?? 0), 6);
// The composition, the category rows and the bill-line rows each add up to the bill (D-42): manual costs once.
var bill = overview.Cost.Total.Cost!.Value;
Assert.Equal(bill, overview.Cost.Composition!.Total.Cost!.Value, 6);
Assert.Equal(bill, overview.CategoryChanges.Sum(r => r.Current?.Cost ?? 0), 6);
Assert.Equal(bill, overview.LineChanges.Sum(r => r.Current?.Cost ?? 0), 6);
Assert.Equal(["Heizung", "Strom", "Wasser"], overview.CategoryChanges.Select(r => r.Name).Order());
Assert.Single(overview.LineChanges, r => r.Kind == OverviewRowKind.ManualCosts);
Assert.Equal(meters["Zähler Netz"], overview.LineChanges.Single(r => r.Kind == OverviewRowKind.Line && r.EnergyTypeId == types["electricity"]).MeterId);
// Compared with the whole of 2024: both years are complete, so the totals are compared as they are (D-07).
Assert.Equal(CostChangeBasis.WholePeriod, overview.CostChange.Basis);
Assert.Equal(overview.PreviousCost!.Total.Cost!.Value, overview.CostChange.Previous!.Value, 6);
Assert.InRange(overview.CostChange.Previous!.Value, 6783.05 - 0.46 - 0.02, 6783.05 - 0.46 + 0.02);
Assert.True(overview.CostChange.Change.PercentApplicable);
Assert.Null(overview.Projection);
// Per type, in its own units, never added across them; a type without meters has no card.
Assert.Equal(["electricity", "water", "heating_oil"], overview.Types.Select(t => types.Single(k => k.Value == t.Type.Id).Key));
var strom = overview.Types.Single(t => t.Type.Id == types["electricity"]);
Assert.Equal(
[(TotalsMeasure.Use, "kWh"), (TotalsMeasure.GridImport, "kWh"), (TotalsMeasure.Generation, "kWh")],
strom.Measures.Select(m => (m.Key.Measure!.Value, m.Unit)));
Assert.All(strom.Measures, m => Assert.Equal(BucketStatus.Available, m.Total.Status));
Assert.Equal(BillingBasis.GridImport, strom.Cost!.Basis);
Assert.Equal(CostChangeBasis.WholePeriod, strom.CostChange.Basis);
var oil = overview.Types.Single(t => t.Type.Id == types["heating_oil"]);
Assert.Equal([TotalsMeasure.Use, TotalsMeasure.Runtime], oil.Measures.Select(m => m.Key.Measure!.Value));
Assert.Equal(["L", "h"], oil.Measures.Select(m => m.Unit));
Assert.Equal(CostStatus.NotPriced, oil.Cost!.Total.Status);
Assert.Null(oil.Cost.Total.Cost);
Assert.Equal(FreshnessState.Historical, strom.Freshness.State);
Assert.Equal(2, overview.EnergyTypes.Count(t => !t.HasMeters));
// The tank's missing price is the attention item, with the tariff deep link (D-52).
Assert.Contains(overview.Cost.Attention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price?.MeterId == meters["Öltank"]);
Assert.Contains(meters["Summe Solar"], overview.MeterNames.Keys);
}
[Fact]
public async Task The_seeded_month_to_date_has_no_data_and_names_the_latest_month_without_showing_it()
{
await LoadReferenceDataAsync();
var query = AnalysisQuery.Default(AnalysisDefaults.Overview);
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.MonthToDate), query.Bucket, query.Comparison);
Assert.True(overview.HasNoData);
Assert.Null(overview.Cost.Total.Cost);
Assert.All(overview.Quantities.Measures, m => Assert.Equal(BucketStatus.Missing, m.Total.Status));
Assert.Equal(D(2026, 5, 31), overview.Availability!.LastDay);
Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Both), overview.Latest);
// "Go to latest data" opens May 2026 as its own period on the Overview (brief §4.3), never silently.
var latest = AnalysisNavigation.LatestData(query, overview.Availability);
Assert.Equal("/?from=2026-05-01&to=2026-05-31", AnalysisLinks.Overview(latest));
var may = await Dashboard().GetOverviewAsync(Custom(D(2026, 5, 1), D(2026, 5, 31)), BucketSize.Auto, PreviousYear);
Assert.False(may.HasNoData);
Assert.NotNull(may.Cost.Total.Cost);
Assert.Equal(may.Cost.Total.Cost!.Value, may.CategoryChanges.Sum(r => r.Current?.Cost ?? 0), 6);
}
[Fact]
public async Task A_year_to_date_is_compared_only_over_the_months_both_years_cover()
{
await LoadReferenceDataAsync();
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.YearToDate), BucketSize.Auto, PreviousYear);
// 2026 ends with May; the same days of 2025 are complete, so only January to May of both is compared (D-07).
Assert.InRange(overview.Cost.Total.Cost!.Value, 2940.19 - 0.02, 2940.19 + 0.02);
Assert.Equal(BucketStatus.Partial, overview.Cost.Total.Availability);
Assert.Equal(CostChangeBasis.MatchedBuckets, overview.CostChange.Basis);
Assert.Equal((D(2026, 1, 1), D(2026, 5, 31)), (overview.CostChange.Matched.Current!.FirstDay, overview.CostChange.Matched.Current.LastDay));
Assert.Equal((D(2025, 1, 1), D(2025, 5, 31)), (overview.CostChange.Matched.Comparison!.FirstDay, overview.CostChange.Matched.Comparison.LastDay));
var matched = Enumerable.Range(0, 5).Sum(i => overview.PreviousCost!.Buckets[i].Cost ?? 0);
Assert.Equal(matched, overview.CostChange.Previous!.Value, 6);
// A partial year is never projected (D-09).
Assert.Null(overview.Projection);
// Quantities carry the reader's own matched change.
var strom = overview.Types.First();
Assert.All(strom.Measures, m => Assert.True(m.Comparison!.Matched.IsComparable));
}
[Fact]
public async Task A_manual_cost_only_instance_agrees_across_the_overview_s_panels_and_its_latest_month()
{
// Brief §11: no meter at all, only manual costs — the bill, the chart, the composition, the rows and the latest
// month agree, and nothing waits for a meter.
await using var box = new CostSandbox(fx);
var category = await box.CategoryAsync($"Manual {Guid.NewGuid():N}", 100);
await box.ManualCostAsync(D(2026, 3, 5), 100, categoryId: category);
await box.ManualCostAsync(D(2026, 3, 20), 40);
await box.ManualCostAsync(D(2026, 5, 10), 60, categoryId: category);
var dashboard = Dashboard();
var year = await dashboard.GetOverviewAsync(Preset(PeriodPreset.Last12Months), BucketSize.Month, ComparisonRequest.None);
Assert.Empty(year.Types);
Assert.Empty(year.Quantities.Measures);
Assert.False(year.HasNoData);
CostAssert.Priced(200, year.Cost.Total);
Assert.Equal(200, year.Cost.Buckets.Sum(b => b.Cost ?? 0), 6);
Assert.Equal(200, year.Cost.Composition!.Total.Cost!.Value, 6);
Assert.Equal(200, year.CategoryChanges.Sum(r => r.Current?.Cost ?? 0), 6);
Assert.Equal([160d, 40d], year.CategoryChanges.Select(r => r.Current!.Cost!.Value));
var manual = Assert.Single(year.LineChanges);
Assert.Equal(OverviewRowKind.ManualCosts, manual.Kind);
Assert.Equal(200, manual.Current!.Cost!.Value, 6);
Assert.Equal(CostChangeBasis.NoComparison, year.CostChange.Basis);
// The latest month rests on manual costs, and opened as its own period it is May's 60 € in every panel.
Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Manual), year.Latest);
var month = await dashboard.GetOverviewAsync(Custom(D(2026, 5, 1), D(2026, 5, 31)), BucketSize.Auto, PreviousYear);
CostAssert.Priced(60, month.Cost.Total);
Assert.Equal(60, month.Cost.Buckets.Sum(b => b.Cost ?? 0), 6);
Assert.Equal(60, month.Cost.Composition!.Total.Cost!.Value, 6);
Assert.Equal(60, month.LineChanges.Single().Current!.Cost!.Value, 6);
Assert.Equal(year.Cost.Buckets[year.Plan.Buckets.ToList().FindIndex(b => b.FirstDay == D(2026, 5, 1))].Cost, month.Cost.Total.Cost);
// This month has nothing yet: no data, with the manual costs' dates to go to.
var mtd = await dashboard.GetOverviewAsync(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, PreviousYear);
Assert.True(mtd.HasNoData);
Assert.Equal((D(2026, 3, 5), D(2026, 5, 10)), (mtd.Availability!.FirstDay, mtd.Availability.LastDay));
}
[Fact]
public async Task An_instance_without_category_members_or_tariffs_still_shows_its_quantities()
{
// Brief §7.1: quantity cards never wait for a price or a category; the composition names the missing step.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), 100, 120, 90);
var overview = await Dashboard().GetOverviewAsync(Custom(D(2025, 1, 1), D(2025, 3, 31)), BucketSize.Month, PreviousYear);
var card = Assert.Single(overview.Types);
var use = Assert.Single(card.Measures);
Assert.Equal(310, use.Total.Value!.Value, 6);
Assert.Equal(CostStatus.NotPriced, card.Cost!.Total.Status);
Assert.Equal(CostSetupGap.NoMembers, overview.Setup!.FirstGap);
var composition = overview.Cost.Composition!;
Assert.Equal([meter], composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized).MeterIds);
Assert.All(composition.Slices.Where(s => s.Kind == CompositionSliceKind.Category), s => Assert.Null(s.Total.Cost));
// Not priced now, and a known 0 € the year before the meter was installed (D-24): no change is stated.
var row = Assert.Single(overview.CategoryChanges);
Assert.Equal((OverviewRowKind.Uncategorized, CostStatus.NotPriced), (row.Kind, row.Current!.Status));
Assert.False(row.Change.Change.IsAvailable);
}
private DashboardService Dashboard()
{
var clock = new FixedTimeProvider(Now);
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId });
return new DashboardService(fx, new CostService(fx, options, clock), clock);
}
private async Task LoadReferenceDataAsync()
{
await using var db = fx.CreateContext();
var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter());
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
}
@@ -0,0 +1,299 @@
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.App.Components.Pages.Overview;
using MeterVault.App.Localization;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Overview;
/// <summary>
/// The Overview's rules without a database (brief §7.1): a cost change is stated over what both periods cover
/// completely (D-07) — the whole totals when both are complete, else the paired buckets both have, else not at all; a
/// projection only for a complete month or year to date after enough days (D-09); the words and links of its rows; and
/// the specific wording of invalid calculations, totals conflicts and overlap hints in attention items (D-53).
/// </summary>
public sealed class OverviewLogicTests
{
private static readonly AttentionNames Names = new(
new Dictionary<int, string> { [1] = "Haus", [2] = "Netz", [4] = "Solar 1", [5] = "Solar 2", [9] = "Summe Solar" },
new Dictionary<int, string> { [1] = "Strom" });
[Fact]
public void Complete_totals_are_compared_as_a_whole()
{
var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31));
var pairs = Pairs(buckets);
var now = Priced(buckets, 100, 0.30);
var before = Priced(buckets, 80, 0.30);
var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, pairs);
Assert.Equal(CostChangeBasis.WholePeriod, change.Basis);
Assert.False(change.IsPartial);
Assert.Equal(90, change.Current!.Value, 6);
Assert.Equal(72, change.Previous!.Value, 6);
Assert.Equal(18, change.Change.Absolute!.Value, 6);
Assert.Equal(25, change.Change.Percent!.Value, 6);
Assert.True(change.Matched.IsContiguous);
Assert.Equal((D(2025, 1, 1), D(2025, 3, 31)), (change.Matched.Current!.FirstDay, change.Matched.Current.LastDay));
Assert.Equal((D(2024, 1, 1), D(2024, 3, 31)), (change.Matched.Comparison!.FirstDay, change.Matched.Comparison.LastDay));
}
[Fact]
public void A_partial_period_is_compared_over_the_buckets_both_have_complete()
{
// January and March are complete on both sides; February is only partly covered now: it is left out of the
// change, which is then stated over two separate stretches.
var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31));
var now = Figures(buckets, (100, BucketStatus.Available), (50, BucketStatus.Partial), (100, BucketStatus.Available));
var before = Figures(buckets, (80, BucketStatus.Available), (80, BucketStatus.Available), (120, BucketStatus.Available));
var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, Pairs(buckets));
Assert.Equal(CostChangeBasis.MatchedBuckets, change.Basis);
Assert.True(change.IsPartial);
Assert.Equal(60, change.Current!.Value, 6);
Assert.Equal(60, change.Previous!.Value, 6);
Assert.Equal(0, change.Change.Direction);
Assert.Equal(2, change.Matched.Pieces.Count);
Assert.False(change.Matched.IsContiguous);
Assert.Equal(D(2025, 3, 31), change.Matched.Current!.LastDay);
}
[Fact]
public void Nothing_complete_on_both_sides_is_not_comparable_and_no_comparison_is_nothing_at_all()
{
var buckets = Buckets(D(2025, 1, 1), D(2025, 2, 28));
var now = Figures(buckets, (100, BucketStatus.Partial), (100, BucketStatus.Available));
var before = Figures(buckets, (100, BucketStatus.Available), (100, BucketStatus.Missing));
var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, Pairs(buckets));
Assert.Equal(CostChangeBasis.NotComparable, change.Basis);
Assert.False(change.Change.IsAvailable);
Assert.False(change.Matched.IsComparable);
var none = OverviewComparison.Between(now.Totals, now.Total, null, null, []);
Assert.Equal(CostChangeBasis.NoComparison, none.Basis);
}
[Fact]
public void A_zero_baseline_has_a_change_but_no_percentage()
{
var buckets = Buckets(D(2025, 1, 1), D(2025, 1, 31));
var now = Priced(buckets, 100, 0.30);
var before = Priced(buckets, 0, 0.30);
var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, Pairs(buckets));
Assert.Equal(30, change.Change.Absolute!.Value, 6);
Assert.False(change.Change.PercentApplicable);
Assert.Equal("percentage not applicable", In("en", () => Format.ChangePercent(change.Change)));
}
[Fact]
public void A_month_to_date_is_projected_only_from_a_complete_figure_after_a_week()
{
// 1 19 September 14:37: 18.6 days elapsed of 30. 10 kWh a day at 0.30 € and 3 € a month standing charge, plus a
// one-off manual cost of 50 € that is not drawn on.
var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, Berlin);
var elapsed = (period.Now - period.From).TotalDays;
var total = MonthFigure(period, usagePerDay: 10, elapsed, standingPerMonth: 3, manual: 50);
var projection = OverviewProjection.For(period, total);
Assert.NotNull(projection);
Assert.Equal(18, projection!.Days);
var running = total.Usage!.Value + total.StandingCharge!.Value;
Assert.Equal((running / elapsed * 30) + 50, projection.Value, 6);
// Not after only a few days, not from a partial figure, never for a complete or a custom period.
var early = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, new DateTimeOffset(2026, 9, 5, 12, 0, 0, TimeSpan.FromHours(2)), Berlin);
Assert.Null(OverviewProjection.For(early, MonthFigure(early, 10, (early.Now - early.From).TotalDays, 3, 0)));
Assert.Null(OverviewProjection.For(period, MonthFigure(period, 10, elapsed, 3, 0, BucketStatus.Partial)));
Assert.Null(OverviewProjection.For(PeriodResolver.Resolve(PeriodPreset.PreviousYear, null, null, Now, Berlin), total));
Assert.Null(OverviewProjection.For(PeriodResolver.Resolve(PeriodPreset.Last12Months, null, null, Now, Berlin), total));
}
[Fact]
public void Rows_are_named_in_the_readers_language_and_link_to_their_scope_with_the_same_dates() => In("de", () =>
{
var query = AnalysisQuery.Default(AnalysisDefaults.Overview).WithPeriod(PeriodPreset.PreviousYear);
var none = CostChange.NoComparison;
var category = new OverviewChangeRow(OverviewRowKind.Category, "Strom", null, null, none) { CategoryId = 4 };
Assert.Equal("Strom", OverviewText.NameOf(category));
Assert.Equal("/trends?scope=category&id=4&metric=cost&period=prev-year", OverviewText.HrefOf(category, query));
var uncategorized = new OverviewChangeRow(OverviewRowKind.Uncategorized, string.Empty, null, null, none);
Assert.Equal("Ohne Kategorie", OverviewText.NameOf(uncategorized));
Assert.Null(OverviewText.HrefOf(uncategorized, query));
var typeCharge = new OverviewChangeRow(OverviewRowKind.StandingCharge, "Strom", null, null, none)
{
StandingCharge = new StandingChargeKey(TariffScope.EnergyType, 1),
EnergyTypeId = 1,
};
Assert.Equal("Grundpreis — Strom", OverviewText.NameOf(typeCharge));
Assert.Equal("/energy/1?period=prev-year", OverviewText.HrefOf(typeCharge, query));
var global = new OverviewChangeRow(OverviewRowKind.StandingCharge, string.Empty, null, null, none) { StandingCharge = new StandingChargeKey(TariffScope.Global, null) };
Assert.Equal("Grundpreis — global", OverviewText.NameOf(global));
var credit = new OverviewChangeRow(OverviewRowKind.Line, "Netz", null, null, none) { MeterId = 2, LineKind = BillLineKind.FeedIn };
Assert.Equal("Einspeisevergütung", OverviewText.DetailOf(credit));
Assert.Equal("/meters/2?tab=analysis&period=prev-year", OverviewText.HrefOf(credit, query));
var manual = new OverviewChangeRow(OverviewRowKind.ManualCosts, string.Empty, null, null, none);
Assert.Equal("Manuelle Kosten", OverviewText.NameOf(manual));
});
[Fact]
public void An_invalid_calculation_says_what_is_wrong_and_about_which_meters() => In("en", () =>
{
var unknown = new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9)
{
Virtual = new VirtualProblem(VirtualProblemKind.UnknownMeter, [12], []),
};
var item = AttentionItems.ForProblem(unknown, Names);
Assert.Equal(
"Summe Solar: the calculation is invalid, so no values can be shown. The formula refers to a meter that does not exist (Meter #12).",
item.Text);
Assert.Equal("Edit calculation", item.ActionText);
var cycle = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.DependencyCycle, [9, 4, 9], []) },
Names);
Assert.EndsWith("(Summe Solar → Solar 1 → Summe Solar).", cycle.Text, StringComparison.Ordinal);
var units = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.UnitMismatch, [4, 5], ["kWh", "m³"]) },
Names);
Assert.EndsWith("The formula adds or subtracts meters in different units (kWh, m³).", units.Text, StringComparison.Ordinal);
var kinds = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.KindMismatch, [4, 1], ["Generation", "Consumption"]) },
Names);
Assert.EndsWith("(Generation, Consumption).", kinds.Text, StringComparison.Ordinal);
// Without the validator's finding the item stays general.
Assert.Equal(
"Summe Solar: the calculation is invalid, so no values can be shown.",
AttentionItems.ForProblem(new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9), Names).Text);
});
[Fact]
public void Calculation_findings_are_worded_in_german() => In("de", () =>
{
var item = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.SelfReference, [9], []) },
Names);
Assert.Equal(
"Summe Solar: Die Berechnung ist ungültig, daher können keine Werte angezeigt werden. Die Formel verweist auf den Zähler selbst (Summe Solar).",
item.Text);
});
[Fact]
public void A_totals_conflict_and_a_billing_workaround_say_what_contradicts_itself() => In("en", () =>
{
var duplicate = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.TotalsProblem, 2) { 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).",
duplicate.Text);
var loop = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.TotalsProblem, 1) { Totals = new TotalsProblem(TotalsProblemKind.ContainmentCycle, 1) },
Names);
Assert.Equal("Haus: the totals configuration contradicts itself. The meter links form a loop.", loop.Text);
var billing = AttentionItems.ForCost(
new CostAttention(CostAttentionKind.BillingConfiguration, 5) { Totals = new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, 5, 1) },
Names);
Assert.StartsWith("Solar 2: the billing setup does not fit and was worked around. The meter has its own price, but its unit", billing.Text, StringComparison.Ordinal);
Assert.EndsWith("(Haus).", billing.Text, StringComparison.Ordinal);
});
[Fact]
public void An_overlap_hint_of_a_kind_the_page_does_not_know_still_says_what_it_is() => In("de", () =>
{
Assert.Equal(
"Der Zähler ist nicht verknüpft und wird als Teil des Gesamtverbrauchs gezählt",
OverlapHintKind.NotLinkedBelowTotalLoad.Display());
var hint = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.PossibleOverlap, 2) { Hint = new OverlapHint((OverlapHintKind)99, 1, 2, 1) },
Names);
Assert.Equal("Netz: 99", hint.Text);
Assert.Equal("/energy/1?tab=meters", hint.ActionHref);
});
[Fact]
public void The_chart_selection_is_read_from_the_address()
{
Assert.Null(OverviewView.ChartKeyOf("http://localhost/"));
Assert.Null(OverviewView.ChartKeyOf("http://localhost/?period=ytd"));
Assert.Equal("t1:use:kWh", OverviewView.ChartKeyOf("http://localhost/?period=ytd&chart=t1%3Ause%3AkWh#top"));
Assert.Equal("#0af", OverviewDonutSlice.SafeColor("#0af"));
Assert.Null(OverviewDonutSlice.SafeColor("red; background:url(x)"));
Assert.Equal("var(--mud-palette-info)", OverviewDonutSlice.PaletteVariable(2));
}
private static IReadOnlyList<BucketPair> Pairs(IReadOnlyList<AnalysisBucket> buckets)
{
var period = Range(buckets[0].FirstDay, buckets[^1].EndDay.AddDays(-1));
var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear));
return ComparisonResolver.PairBuckets(period, resolution.Period!, buckets);
}
/// <summary>One line at 0.30 €/kWh whose monthly quantity and availability are given per bucket.</summary>
private static CostResult Figures(IReadOnlyList<AnalysisBucket> buckets, params (double Amount, BucketStatus Availability)[] months)
{
var parts = CostCalculator.Parts(buckets);
var quantities = parts.Select(p => months[p.BucketIndex].Availability == BucketStatus.Missing
? CostQuantity.Unknown(p)
: CostQuantity.Known(p, months[p.BucketIndex].Amount, months[p.BucketIndex].Availability)).ToList();
return CostCalculator.Calculate(new CostRequest(
buckets, D(2039, 12, 31), TariffBook.Create([Price(TariffComponent.UnitPrice, 0.30, "EUR/kWh")], "EUR"),
[new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities)]));
}
/// <summary>A month to date: <paramref name="usagePerDay"/> kWh a day at 0.30 €, a monthly standing charge and a manual cost.</summary>
private static CostAmount MonthFigure(
ResolvedPeriod period, double usagePerDay, double elapsed, double standingPerMonth, double manual, BucketStatus availability = BucketStatus.Available)
{
var plan = BucketPlanner.Plan(period, BucketSize.Month);
var parts = CostCalculator.Parts(plan.Buckets);
var quantities = parts.Select(p => CostQuantity.Known(p, usagePerDay * elapsed, availability)).ToList();
var today = PeriodResolver.LocalDate(period.Now, period.Zone);
ManualCost[] manualCosts = manual > 0 ? [new ManualCost { Id = 1, PeriodStart = period.FirstDay.AddDays(2), PeriodEnd = period.FirstDay.AddDays(2), Amount = manual }] : [];
var result = CostCalculator.Calculate(new CostRequest(
plan.Buckets,
today,
TariffBook.Create([Price(TariffComponent.UnitPrice, 0.30, "EUR/kWh"), Price(TariffComponent.BasePrice, standingPerMonth, "EUR/month", id: 2)], "EUR"),
[new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities)],
[StandingChargeScope.ForEnergyType(1, new ServicePeriod(D(2020, 1, 1)))],
manualCosts));
return result.Total;
}
private static Tariff Price(TariffComponent component, double value, string unit, int id = 1) => new()
{
Id = id,
ScopeType = TariffScope.EnergyType,
ScopeId = 1,
Component = component,
Value = value,
Unit = unit,
ValidFrom = D(2000, 1, 1),
};
}
@@ -0,0 +1,174 @@
using System.Net;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence;
using MeterVault.Integration.Tests.Costing;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Overview;
/// <summary>
/// The Overview page (brief §7.1) rendered by the real app on the frozen clock of <see cref="CostSandbox.Now"/>
/// (server prerender, which reads the initial load): this month to date of the seeded instance says there is no data
/// and offers the latest month as its own period instead of showing it; the previous year shows the sheet's bill with
/// the energy type cards, the attention item with its tariff link, the change table and the composition, in English and
/// German; a manual-cost-only instance shows its month without a meter. Interactive behaviour (chart, toggles) is covered
/// by the pure tests and the browser check.
/// </summary>
[Collection("Timescale")]
public sealed class OverviewPageTests(TimescaleFixture fx) : IAsyncLifetime
{
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task The_seeded_overview_explains_an_empty_month_and_shows_the_previous_year_s_bill()
{
await LoadReferenceDataAsync();
int strom;
await using (var db = fx.CreateContext())
{
strom = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => (int)t.Id).FirstAsync();
}
using var app = new FrozenApp(fx.ConnectionString);
// Month to date (the default): no data, the dates that have data, and the latest month as its own period — the
// panels are not silently switched to May.
var empty = await app.GetAsync("/");
Assert.Contains("<h1", empty, StringComparison.Ordinal);
Assert.Contains("No data for this period", empty, StringComparison.Ordinal);
Assert.Contains("to May 31, 2026.", empty, StringComparison.Ordinal);
Assert.Contains("Go to latest data", empty, StringComparison.Ordinal);
Assert.Contains("href=\"/?from=2026-05-01&to=2026-05-31\"", empty, StringComparison.Ordinal);
Assert.Contains("Latest month with data: May 2026 (Meter data and manual costs)", empty, StringComparison.Ordinal);
Assert.DoesNotContain("Total (the bill)", empty, StringComparison.Ordinal);
Assert.Contains($"href=\"/energy/{strom}?period=mtd\"", empty, StringComparison.Ordinal);
// The previous year: the sheet's bill (7,907.64 € ± 0.02), per-type cards with their own units, what changed,
// the composition, and the tank's missing price with its tariff link.
var year = await app.GetAsync("/?period=prev-year");
Assert.Matches("7,907\\.6[3-6] €", year);
Assert.Contains("Total cost", year, StringComparison.Ordinal);
Assert.Contains("This period: Complete", year, StringComparison.Ordinal);
Assert.Contains("15,661 kWh", year, StringComparison.Ordinal);
Assert.Contains("213 m³", year, StringComparison.Ordinal);
Assert.Contains("2,100 L", year, StringComparison.Ordinal);
Assert.Contains("Billed by grid import", year, StringComparison.Ordinal);
Assert.Contains("Not priced (no tariff)", year, StringComparison.Ordinal);
Assert.Contains("Öltank: Unit price not set up.", year, StringComparison.Ordinal);
Assert.Contains("/admin/tariffs?scope=type", year, StringComparison.Ordinal);
Assert.Contains("What changed", year, StringComparison.Ordinal);
Assert.Contains("Total (the bill)", year, StringComparison.Ordinal);
Assert.Contains("Cost composition", year, StringComparison.Ordinal);
Assert.Contains("Jan 1 Dec 31, 2025 compared with Jan 1 Dec 31, 2024", year, StringComparison.Ordinal);
Assert.Contains($"href=\"/energy/{strom}?period=prev-year\"", year, StringComparison.Ordinal);
Assert.Contains("No meters yet:", year, StringComparison.Ordinal);
Assert.DoesNotContain("No data for this period", year, StringComparison.Ordinal);
// The same in German: the reader's words and numbers; names are user data.
var german = await app.GetAsync("/?period=prev-year", "de");
Assert.Matches("7\\.907,6[3-6] €", german);
Assert.Contains("Gesamtkosten", german, StringComparison.Ordinal);
Assert.Contains("Was sich verändert hat", german, StringComparison.Ordinal);
Assert.Contains("Summe (die Rechnung)", german, StringComparison.Ordinal);
Assert.Contains("Öltank: Arbeitspreis nicht hinterlegt.", german, StringComparison.Ordinal);
Assert.Contains("Heizöl", german, StringComparison.Ordinal);
Assert.DoesNotContain("Total cost", german, StringComparison.Ordinal);
// The latest month opened as its own period shows May's figures.
var may = await app.GetAsync("/?from=2026-05-01&to=2026-05-31");
Assert.Contains("May 1 May 31, 2026", may, StringComparison.Ordinal);
Assert.Contains("Total (the bill)", may, StringComparison.Ordinal);
// A chart selection in the address is kept: the measure is the selected option.
var chart = await app.GetAsync($"/?period=prev-year&chart=t{strom}:grid-import:kWh");
Assert.Contains("Strom · Grid import (kWh)", chart, StringComparison.Ordinal);
}
[Fact]
public async Task A_manual_cost_only_instance_shows_its_month_without_any_meter()
{
await using var box = new CostSandbox(fx);
var category = await box.CategoryAsync($"Manual {Guid.NewGuid():N}", 100);
await box.ManualCostAsync(D(2026, 5, 10), 60, categoryId: category);
await box.ManualCostAsync(D(2026, 5, 20), 15);
using var app = new FrozenApp(fx.ConnectionString);
var html = await app.GetAsync("/?from=2026-05-01&to=2026-05-31");
Assert.Contains("75.00 €", html, StringComparison.Ordinal);
Assert.Contains("60.00 €", html, StringComparison.Ordinal);
Assert.Contains("15.00 €", html, StringComparison.Ordinal);
Assert.Contains("Uncategorized", html, StringComparison.Ordinal);
Assert.Contains("Latest month with data: May 2026 (Manual costs)", html, StringComparison.Ordinal);
Assert.DoesNotContain("No data for this period", html, StringComparison.Ordinal);
}
private async Task LoadReferenceDataAsync()
{
await using var db = fx.CreateContext();
var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter());
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
/// <summary>The app on the frozen clock of <see cref="CostSandbox.Now"/>; pages come back HTML-decoded.</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))));
}
public async Task<string> GetAsync(string path, string culture = "en")
{
using var client = _app.CreateClient();
client.DefaultRequestHeaders.Add(
"Cookie",
CookieRequestCultureProvider.DefaultCookieName + "="
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture))));
using var response = await client.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode();
return WebUtility.HtmlDecode(await response.Content.ReadAsStringAsync());
}
public void Dispose()
{
_app.Dispose();
_root.Dispose();
}
}
}
@@ -0,0 +1,108 @@
using System.Diagnostics;
using System.Globalization;
namespace MeterVault.Integration.Tests.Performance;
/// <summary>
/// Counts the SQL commands one async flow sends — through EF Core and through the reader's plain Npgsql commands
/// alike — by listening to Npgsql's own tracing (<c>ActivitySource "Npgsql"</c>). Only commands of the flow that
/// started the counter are counted, so other work in the process does not leak in. While no counter is open, no
/// listener exists and Npgsql creates no activities: the timed runs are not slowed down by it.
/// </summary>
internal sealed class CommandCounter : IDisposable
{
private static readonly AsyncLocal<CommandCounter?> Current = new();
private readonly ActivityListener _listener;
private readonly List<string> _statements = [];
private readonly CommandCounter? _previous;
private TimeSpan _duration;
private CommandCounter()
{
_previous = Current.Value;
_listener = new ActivityListener
{
ShouldListenTo = source => source.Name.StartsWith("Npgsql", StringComparison.Ordinal),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = Stopped,
};
ActivitySource.AddActivityListener(_listener);
}
/// <summary>The commands counted so far.</summary>
public int Count
{
get
{
lock (_statements)
{
return _statements.Count;
}
}
}
/// <summary>
/// The summed duration of the counted commands: from execution to the reader's close, so it includes reading the
/// rows (and whatever the caller does per row while reading them).
/// </summary>
public TimeSpan Duration
{
get
{
lock (_statements)
{
return _duration;
}
}
}
/// <summary>Every counted command in order: its duration and the first line of its text.</summary>
public IReadOnlyList<string> Statements
{
get
{
lock (_statements)
{
return [.. _statements];
}
}
}
/// <summary>Starts counting the commands of the calling async flow.</summary>
public static CommandCounter Start()
{
var counter = new CommandCounter();
Current.Value = counter;
return counter;
}
public void Dispose()
{
Current.Value = _previous;
_listener.Dispose();
}
private void Stopped(Activity activity)
{
if (!ReferenceEquals(Current.Value, this))
{
return;
}
// A command activity carries its text (db.query.text since Npgsql 10; db.statement before).
var text = activity.GetTagItem("db.query.text") as string ?? activity.GetTagItem("db.statement") as string;
if (text is null)
{
return;
}
var first = text.TrimStart().Split('\n', 2)[0].Trim();
lock (_statements)
{
_statements.Add(string.Create(
CultureInfo.InvariantCulture, $"{activity.Duration.TotalMilliseconds,8:F1} ms {(first.Length > 140 ? first[..140] + "" : first)}"));
_duration += activity.Duration;
}
}
}
@@ -0,0 +1,281 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit.Abstractions;
namespace MeterVault.Integration.Tests.Performance;
/// <summary>How the measured database was prepared: loaded now or reused, and what each step took.</summary>
internal sealed record PreparedDatabase(
DatasetManifest Manifest,
bool Loaded,
RebuildStats? Rebuild,
double? CompressSeconds,
int CompressedChunks);
/// <summary>The startup rebuild (NormalizationUpgrade) over the whole dataset.</summary>
internal sealed record RebuildStats(double Seconds, int Meters, int Rebuilt, bool MeasuredInThisRun);
/// <summary>
/// The database of one performance run: a fresh TimescaleDB container (the image the fixture pins), or an existing
/// database from <c>METERVAULT_PERF_DB</c>. Prepares it the way a real instance gets there — migrations, the default
/// seed, the raw dataset, the startup rebuild (<see cref="NormalizationUpgrade"/>), and the compression policy's work
/// on raw chunks older than 30 days — and hands out contexts for the readers.
/// </summary>
internal sealed class PerfDatabase : IDbContextFactory<MeterVaultDbContext>, IAsyncDisposable
{
/// <summary>The pinned image of <see cref="TimescaleFixture"/>.</summary>
public const string Image = "timescale/timescaledb:2.17.2-pg16";
/// <summary>The frozen "now" the measured dataset ends at: 19 September 2026, 14:37 Berlin, as the cost tests use.</summary>
public static readonly DateTimeOffset FrozenNow = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime();
private const string RebuildKey = "perf_rebuild";
private readonly PostgreSqlContainer? _container;
private PerfDatabase(PostgreSqlContainer? container, string connectionString)
{
_container = container;
// Long statements are allowed: a 1,000-meter read on a busy machine must be measured, not time out.
ConnectionString = new NpgsqlConnectionStringBuilder(connectionString) { CommandTimeout = 600, IncludeErrorDetail = true }.ConnectionString;
}
public string ConnectionString { get; }
/// <summary>Where the database came from, for the report.</summary>
public string Origin => _container is null ? "existing database (METERVAULT_PERF_DB)" : $"fresh container ({Image})";
public static async Task<PerfDatabase> OpenAsync(PerfSettings settings, PerfLog log)
{
if (settings.Database is { } external)
{
log.Write("Using the database from METERVAULT_PERF_DB");
return new PerfDatabase(null, external);
}
log.Write($"Starting a {Image} container…");
var container = new PostgreSqlBuilder(Image)
.WithDatabase("metervault")
.WithUsername("metervault")
.WithPassword("metervault")
.Build();
await container.StartAsync();
return new PerfDatabase(container, container.GetConnectionString());
}
public MeterVaultDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<MeterVaultDbContext>()
.UseNpgsql(ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
.UseSnakeCaseNamingConvention()
.Options;
return new MeterVaultDbContext(options);
}
/// <summary>
/// Migrates and seeds, loads the dataset unless the database already holds it, rebuilds what is not built yet, and
/// compresses raw chunks older than 30 days (what the compression policy does on a running instance).
/// </summary>
public async Task<PreparedDatabase> PrepareAsync(PerfSettings settings, PerfLog log)
{
await using (var db = CreateDbContext())
{
db.Database.SetCommandTimeout(TimeSpan.FromMinutes(10));
await db.Database.MigrateAsync();
await DatabaseSeeder.SeedAsync(db);
// As in TimescaleFixture: the scheduled compression job must not race the load and rebuild. Compression
// is applied explicitly below, once, the way the policy would have left the chunks.
await db.Database.ExecuteSqlRawAsync(
"SELECT alter_job(job_id, scheduled => false) FROM timescaledb_information.jobs " +
"WHERE proc_name IN ('policy_compression', 'policy_columnstore');");
}
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync();
var manifest = await SyntheticDataset.ReadMarkerAsync(connection);
var loaded = false;
if (manifest is null)
{
var zone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
log.Write(string.Create(CultureInfo.InvariantCulture, $"Loading the synthetic dataset (scale {settings.Scale}, now {FrozenNow:O})…"));
manifest = await SyntheticDataset.LoadAsync(ConnectionString, FrozenNow, zone, settings.Scale, log.Write);
loaded = true;
}
else
{
log.Write(string.Create(CultureInfo.InvariantCulture, $"Reusing the loaded dataset ({manifest.MeterCount} meters, now {manifest.Now:O})"));
}
var rebuild = await RebuildAsync(connection, manifest, log);
var (compressSeconds, compressed) = await CompressAsync(connection, manifest.Now, log);
if (loaded || rebuild?.MeasuredInThisRun == true || compressed > 0)
{
log.Write("VACUUM ANALYZE…");
await ExecuteAsync(connection, "VACUUM ANALYZE");
}
return new PreparedDatabase(manifest, loaded, rebuild, compressSeconds, compressed);
}
/// <summary>
/// Runs the startup rebuild when stored consumption is missing or outdated — timed, with a frozen clock at the
/// dataset's "now" — and remembers its duration in the database, so a later reuse can still report it.
/// </summary>
private async Task<RebuildStats?> RebuildAsync(NpgsqlConnection connection, DatasetManifest manifest, PerfLog log)
{
var meters = Convert.ToInt32(await ScalarAsync(connection, "SELECT count(*) FROM meter"), CultureInfo.InvariantCulture);
var built = Convert.ToInt32(
await ScalarAsync(connection, $"SELECT count(*) FROM meter_rollup_state WHERE revision >= {NormalizationUpgrade.CurrentRevision}"),
CultureInfo.InvariantCulture);
var revision = await ScalarAsync(connection, $"SELECT value::text FROM app_setting WHERE key = '{NormalizationUpgrade.SettingKey}'");
if (built >= meters && revision is not null)
{
return await ScalarAsync(connection, $"SELECT value::text FROM app_setting WHERE key = '{RebuildKey}'") is string json
? JsonSerializer.Deserialize<RebuildStats>(json)! with { MeasuredInThisRun = false }
: null;
}
log.Write(string.Create(CultureInfo.InvariantCulture, $"Rebuilding {meters} meters through NormalizationUpgrade…"));
var watch = Stopwatch.StartNew();
int rebuilt;
await using (var db = CreateDbContext())
{
var normalization = Normalization(db, manifest);
var upgrade = new NormalizationUpgrade(db, normalization, new PerfLogger<NormalizationUpgrade>(log));
rebuilt = await upgrade.RunAsync();
}
watch.Stop();
var stats = new RebuildStats(watch.Elapsed.TotalSeconds, meters, rebuilt, MeasuredInThisRun: true);
log.Write(string.Create(CultureInfo.InvariantCulture, $"Rebuilt {rebuilt} of {meters} meters in {stats.Seconds:F1} s"));
await using var command = new NpgsqlCommand(
"INSERT INTO app_setting (key, value) VALUES (@key, @value) ON CONFLICT (key) DO UPDATE SET value = excluded.value", connection);
command.Parameters.AddWithValue("key", RebuildKey);
command.Parameters.Add(new NpgsqlParameter("value", NpgsqlTypes.NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(stats) });
await command.ExecuteNonQueryAsync();
return stats;
}
/// <summary>A normalization service as the app builds it, in the dataset's zone, on a clock frozen at its "now".</summary>
public static NormalizationService Normalization(MeterVaultDbContext db, DatasetManifest manifest) =>
new(db, NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = manifest.Zone }),
new FixedTimeProvider(manifest.Now));
/// <summary>Compresses the raw chunks the 30-day policy would have compressed by the dataset's "now".</summary>
private static async Task<(double? Seconds, int Chunks)> CompressAsync(NpgsqlConnection connection, DateTimeOffset now, PerfLog log)
{
await using var pending = new NpgsqlCommand(
"""
SELECT count(*) FROM timescaledb_information.chunks
WHERE hypertable_name = 'reading' AND NOT is_compressed AND range_end <= @horizon
""", connection);
pending.Parameters.AddWithValue("horizon", now.AddDays(-30).UtcDateTime);
var count = Convert.ToInt32(await pending.ExecuteScalarAsync(), CultureInfo.InvariantCulture);
if (count == 0)
{
return (null, 0);
}
log.Write(string.Create(CultureInfo.InvariantCulture, $"Compressing {count} raw chunks older than 30 days…"));
var watch = Stopwatch.StartNew();
await using var compress = new NpgsqlCommand(
"SELECT count(compress_chunk(c, if_not_compressed => true)) FROM show_chunks('reading', older_than => @horizon) c", connection)
{
CommandTimeout = 0,
};
compress.Parameters.AddWithValue("horizon", now.AddDays(-30).UtcDateTime);
await compress.ExecuteScalarAsync();
return (watch.Elapsed.TotalSeconds, count);
}
public static async Task<object?> ScalarAsync(NpgsqlConnection connection, string sql)
{
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 0 };
var value = await command.ExecuteScalarAsync();
return value is DBNull ? null : value;
}
public static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
{
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 0 };
await command.ExecuteNonQueryAsync();
}
public async ValueTask DisposeAsync()
{
if (_container is not null)
{
await _container.DisposeAsync();
}
}
}
/// <summary>Timestamped progress to <c>perf-&lt;label&gt;.log</c> in the output directory and to the test output.</summary>
internal sealed class PerfLog : IDisposable
{
private readonly StreamWriter _file;
private readonly ITestOutputHelper? _output;
private readonly Stopwatch _clock = Stopwatch.StartNew();
public PerfLog(PerfSettings settings, string name, ITestOutputHelper? output)
{
Directory.CreateDirectory(settings.OutputDirectory);
Path = System.IO.Path.Combine(settings.OutputDirectory, $"{name}-{settings.Label}.log");
_file = new StreamWriter(Path, append: false) { AutoFlush = true };
_output = output;
}
public string Path { get; }
public void Write(string message)
{
var line = string.Create(CultureInfo.InvariantCulture, $"[{DateTimeOffset.Now:HH:mm:ss} +{_clock.Elapsed.TotalSeconds,7:F1}s] {message}");
lock (_file)
{
_file.WriteLine(line);
}
try
{
_output?.WriteLine(line);
}
catch (InvalidOperationException)
{
// The test has finished; the file still has it.
}
}
public void Dispose() => _file.Dispose();
}
/// <summary>Routes the upgrade's progress ("Rebuilt 100 of 1000 meter(s)") into the perf log.</summary>
internal sealed class PerfLogger<T>(PerfLog log) : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (IsEnabled(logLevel))
{
log.Write($"{typeof(T).Name}: {formatter(state, exception)}{(exception is null ? string.Empty : " " + exception.Message)}");
}
}
}
@@ -0,0 +1,226 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Npgsql;
namespace MeterVault.Integration.Tests.Performance;
/// <summary>The timings of one scenario: every measured run, and the SQL a separate counted run sent.</summary>
internal sealed record ScenarioTiming(
string Id,
string Description,
IReadOnlyList<double> Milliseconds,
int Commands,
IReadOnlyList<string> Statements,
string Shape)
{
/// <summary>The summed duration of the SQL commands in the counted run (execution to reader close).</summary>
public double SqlMilliseconds { get; init; }
public double Median => Percentile(0.5);
public double P95 => Percentile(0.95);
public double Min => Milliseconds.Min();
public double Max => Milliseconds.Max();
/// <summary>Nearest-rank percentile; the median of an even count is the mean of the middle two.</summary>
private double Percentile(double p)
{
var sorted = Milliseconds.Order().ToArray();
if (p == 0.5 && sorted.Length % 2 == 0)
{
return (sorted[(sorted.Length / 2) - 1] + sorted[sorted.Length / 2]) / 2;
}
var rank = (int)Math.Ceiling(p * sorted.Length);
return sorted[Math.Clamp(rank - 1, 0, sorted.Length - 1)];
}
}
/// <summary>Markdown building and the facts about the machine and database a result was taken on.</summary>
internal static class PerfReport
{
public static string F(double value, int digits = 1) => value.ToString("F" + digits.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture);
public static string N(long value) => value.ToString("N0", CultureInfo.InvariantCulture);
/// <summary>A markdown table cell: pipes escaped, newlines flattened.</summary>
public static string Cell(string text) => text.Replace("|", "\\|", StringComparison.Ordinal).Replace('\n', ' ');
/// <summary>CPU model, cores, memory, OS and runtime.</summary>
public static IReadOnlyList<(string Name, string Value)> Machine()
{
var memory = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
return
[
("CPU", CpuModel()),
("Logical processors", Environment.ProcessorCount.ToString(CultureInfo.InvariantCulture)),
("Memory visible to .NET", F(memory / 1024d / 1024 / 1024) + " GiB"),
("OS", RuntimeInformation.OSDescription),
(".NET", RuntimeInformation.FrameworkDescription),
("Docker", Docker()),
];
}
/// <summary>The share of all logical processors busy over <paramref name="window"/> (Windows only): how loaded the machine was.</summary>
public static async Task<string> HostLoadAsync(TimeSpan window)
{
if (!OperatingSystem.IsWindows() || !NativeMethods.GetSystemTimes(out var idle1, out var kernel1, out var user1))
{
return "n/a";
}
await Task.Delay(window);
if (!NativeMethods.GetSystemTimes(out var idle2, out var kernel2, out var user2))
{
return "n/a";
}
// Kernel time includes idle time.
var total = (kernel2 - kernel1) + (user2 - user1);
var busy = total - (idle2 - idle1);
return total <= 0 ? "n/a" : F(100d * busy / total, 0) + " % of all logical processors busy (sampled over " + F(window.TotalSeconds, 0) + " s)";
}
/// <summary>PostgreSQL/Timescale versions and the settings that shape plans.</summary>
public static async Task<IReadOnlyList<(string Name, string Value)>> DatabaseAsync(NpgsqlConnection connection)
{
var rows = new List<(string, string)>
{
("PostgreSQL", (await PerfDatabase.ScalarAsync(connection, "SHOW server_version"))?.ToString() ?? "?"),
("TimescaleDB", (await PerfDatabase.ScalarAsync(connection, "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"))?.ToString() ?? "?"),
};
await using var command = new NpgsqlCommand(
"""
SELECT name, setting || coalesce(' ' || unit, '') FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'effective_cache_size', 'max_parallel_workers_per_gather', 'max_worker_processes',
'jit', 'random_page_cost', 'timescaledb.max_tuples_decompressed_per_dml_transaction')
ORDER BY name
""", connection);
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
return rows;
}
/// <summary>Row counts and on-disk sizes of the tables the pipeline and the reader use.</summary>
public static async Task<IReadOnlyList<(string Table, long Rows, string Size, string Note)>> TablesAsync(NpgsqlConnection connection)
{
var result = new List<(string, long, string, string)>();
(string Table, bool Hypertable)[] tables =
[
("reading", true), ("consumption", true), ("consumption_rollup", false), ("consumption_rollup_month", false),
("meter_coverage", false), ("meter_rollup_state", false), ("meter_event", false), ("meter", false), ("tariff", false),
("manual_cost", false), ("meter_link", false), ("cost_category_member", false),
];
foreach (var (table, hypertable) in tables)
{
var rows = Convert.ToInt64(await PerfDatabase.ScalarAsync(connection, $"SELECT count(*) FROM {table}"), CultureInfo.InvariantCulture);
var bytes = Convert.ToInt64(
await PerfDatabase.ScalarAsync(connection, hypertable ? $"SELECT hypertable_size('{table}')" : $"SELECT pg_total_relation_size('{table}')"),
CultureInfo.InvariantCulture);
var note = string.Empty;
if (hypertable)
{
note = (await PerfDatabase.ScalarAsync(
connection,
$"SELECT count(*) || ' chunks, ' || count(*) FILTER (WHERE is_compressed) || ' compressed' FROM timescaledb_information.chunks WHERE hypertable_name = '{table}'"))
?.ToString() ?? string.Empty;
}
result.Add((table, rows, F(bytes / 1024d / 1024) + " MiB", note));
}
return result;
}
private static string CpuModel()
{
try
{
if (OperatingSystem.IsWindows())
{
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
if (key?.GetValue("ProcessorNameString") is string name)
{
return name.Trim();
}
}
else if (File.Exists("/proc/cpuinfo"))
{
var line = File.ReadLines("/proc/cpuinfo").FirstOrDefault(l => l.StartsWith("model name", StringComparison.Ordinal));
if (line is not null)
{
return line[(line.IndexOf(':', StringComparison.Ordinal) + 1)..].Trim();
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
return "unknown (" + ex.Message + ")";
}
return "unknown";
}
private static string Docker()
{
try
{
using var process = Process.Start(new ProcessStartInfo("docker", "info --format \"{{.ServerVersion}}|{{.OperatingSystem}}|{{.NCPU}} CPUs|{{.MemTotal}}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
if (process is null)
{
return "unknown";
}
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(10_000);
var parts = output.Split('|');
if (parts.Length == 4 && long.TryParse(parts[3], CultureInfo.InvariantCulture, out var bytes))
{
parts[0] = "Engine " + parts[0];
parts[3] = F(bytes / 1024d / 1024 / 1024) + " GiB";
return string.Join(", ", parts);
}
return output.Length > 0 ? output : "unknown";
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
return "unknown (" + ex.Message + ")";
}
}
/// <summary>Appends a markdown table.</summary>
public static void Table(StringBuilder md, IReadOnlyList<string> header, IEnumerable<IReadOnlyList<string>> rows)
{
md.Append("| ").AppendJoin(" | ", header).AppendLine(" |");
md.Append('|').AppendJoin("|", header.Select(_ => "---")).AppendLine("|");
foreach (var row in rows)
{
md.Append("| ").AppendJoin(" | ", row.Select(Cell)).AppendLine(" |");
}
md.AppendLine();
}
private static class NativeMethods
{
[DllImport("kernel32.dll", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetSystemTimes(out long idleTime, out long kernelTime, out long userTime);
}
}
@@ -0,0 +1,95 @@
using System.Globalization;
namespace MeterVault.Integration.Tests.Performance;
/// <summary>
/// The environment switches of the performance measurement (brief §9.10, D-56). Everything is opt-in: without
/// <c>METERVAULT_PERF=1</c> the performance facts are skipped, so the normal suite neither runs nor slows down.
/// </summary>
/// <remarks>
/// <list type="table">
/// <item><term>METERVAULT_PERF</term><description><c>1</c> runs the performance facts.</description></item>
/// <item><term>METERVAULT_PERF_LABEL</term><description>Names the result files (<c>results-&lt;label&gt;.md</c>); default <c>local</c>.</description></item>
/// <item><term>METERVAULT_PERF_OUT</term><description>Where results go; default <c>%TEMP%/mv_analysis/perf</c>.</description></item>
/// <item><term>METERVAULT_PERF_RUNS</term><description>Measured runs per scenario (default 10), after <c>METERVAULT_PERF_WARMUP</c> warm-ups (default 2).</description></item>
/// <item><term>METERVAULT_PERF_DB</term><description>A connection string to use instead of a fresh container. A database that already holds
/// the dataset is reused as it is (no load, no rebuild), so a re-run on a quiet machine only measures.</description></item>
/// <item><term>METERVAULT_PERF_LOAD_DB</term><description>For the loader fact: an app-migrated database (old or new schema) to load the raw
/// dataset into, for the page-level comparison.</description></item>
/// <item><term>METERVAULT_PERF_NOW</term><description>For the loader fact: the instant the dataset ends at (ISO 8601); default the current time.</description></item>
/// <item><term>METERVAULT_PERF_SCALE</term><description>Scales every meter group (default 1 = 1,000 meters); only for quick trial runs.</description></item>
/// <item><term>METERVAULT_PERF_NOTE</term><description>Free text copied into the report (e.g. "machine busy with other builds").</description></item>
/// </list>
/// </remarks>
internal sealed record PerfSettings(
bool Enabled,
string Label,
string OutputDirectory,
int Runs,
int Warmup,
string? Database,
string? LoadDatabase,
DateTimeOffset? Now,
double Scale,
string? Note)
{
public const string EnabledVariable = "METERVAULT_PERF";
public const string LoadDatabaseVariable = "METERVAULT_PERF_LOAD_DB";
public static PerfSettings Current { get; } = Read();
private static PerfSettings Read()
{
static string? Env(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value.Trim() : null;
static int Int(string name, int fallback) =>
int.TryParse(Env(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value >= 0 ? value : fallback;
var scale = double.TryParse(Env("METERVAULT_PERF_SCALE"), NumberStyles.Float, CultureInfo.InvariantCulture, out var s) && s > 0 ? s : 1;
DateTimeOffset? now = DateTimeOffset.TryParse(Env("METERVAULT_PERF_NOW"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var n)
? n.ToUniversalTime()
: null;
return new PerfSettings(
Enabled: Env(EnabledVariable) == "1",
Label: Env("METERVAULT_PERF_LABEL") ?? "local",
OutputDirectory: Env("METERVAULT_PERF_OUT") ?? Path.Combine(Path.GetTempPath(), "mv_analysis", "perf"),
Runs: Math.Max(1, Int("METERVAULT_PERF_RUNS", 10)),
Warmup: Int("METERVAULT_PERF_WARMUP", 2),
Database: Env("METERVAULT_PERF_DB"),
LoadDatabase: Env(LoadDatabaseVariable),
Now: now,
Scale: scale,
Note: Env("METERVAULT_PERF_NOTE"));
}
}
/// <summary>A fact that runs only with <c>METERVAULT_PERF=1</c>; otherwise it is reported as skipped.</summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class PerfFactAttribute : FactAttribute
{
public PerfFactAttribute()
{
if (!PerfSettings.Current.Enabled)
{
Skip = $"Performance measurement; set {PerfSettings.EnabledVariable}=1 to run it.";
}
}
}
/// <summary>
/// A fact that runs only with <c>METERVAULT_PERF=1</c> and a target database in <c>METERVAULT_PERF_LOAD_DB</c>: it loads
/// the synthetic raw dataset into a database an app has migrated, for the page-level before/after comparison.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class PerfLoadFactAttribute : FactAttribute
{
public PerfLoadFactAttribute()
{
if (!PerfSettings.Current.Enabled || PerfSettings.Current.LoadDatabase is null)
{
Skip = $"Loads the synthetic dataset into an existing database; set {PerfSettings.EnabledVariable}=1 and {PerfSettings.LoadDatabaseVariable}.";
}
}
}
@@ -0,0 +1,557 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Rollups;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Options;
using Npgsql;
using static MeterVault.Integration.Tests.Performance.PerfReport;
using Xunit.Abstractions;
namespace MeterVault.Integration.Tests.Performance;
/// <summary>
/// Reader and cost timings on the synthetic 1,000-meter decade (brief §9.10, D-56): the brief's target (cached metadata
/// plus a ten-year monthly request for 100 meters within two seconds), portfolio, energy-type, meter, virtual and bill
/// requests, the refusals a 1,000-meter selection meets before any SQL, the SQL each request sends, and the plans of
/// the reader's main queries. Writes <c>results-&lt;label&gt;.md</c> (and <c>.json</c>) to the output directory.
/// </summary>
/// <remarks>
/// Skipped unless <c>METERVAULT_PERF=1</c> (see <see cref="PerfSettings"/>). Run it alone, in Release, on a quiet machine:
/// <c>dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests"</c>.
/// The numbers are measured, not asserted; only correctness of the measured requests (and the zero-SQL refusals) is.
/// </remarks>
[Trait("Category", "Performance")]
public sealed class ReaderTimingTests(ITestOutputHelper output)
{
/// <summary>The brief's proposed review target for (a).</summary>
private const double TargetMilliseconds = 2000;
[PerfFact]
public async Task Reader_and_cost_timings_on_a_synthetic_1000_meter_decade()
{
var settings = PerfSettings.Current;
using var log = new PerfLog(settings, "reader", output);
var loadBefore = await HostLoadAsync(TimeSpan.FromSeconds(3));
log.Write($"Host load before: {loadBefore}");
await using var database = await PerfDatabase.OpenAsync(settings, log);
var prepared = await database.PrepareAsync(settings, log);
var manifest = prepared.Manifest;
var zone = TimeZoneInfo.FindSystemTimeZoneById(manifest.Zone);
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = manifest.Zone, Currency = "EUR" });
var reader = new AnalysisReader(database, options);
var costs = new CostReader(database, reader, options);
var now = manifest.Now;
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, zone).DateTime);
var currentMonth = new DateOnly(today.Year, today.Month, 1);
var firstMonth = currentMonth.AddMonths(-119);
ResolvedPeriod Custom(DateOnly first, DateOnly last) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now, zone);
ResolvedPeriod Preset(PeriodPreset preset) => PeriodResolver.Resolve(preset, null, null, now, zone);
var tenYears = Custom(firstMonth, currentMonth.AddMonths(1).AddDays(-1));
var lastYearByDay = Custom(today.AddDays(-364), today);
var last12 = Preset(PeriodPreset.Last12Months);
var selection = manifest.Selection100;
var all = manifest.AllMeterIds;
var electricity = manifest.EnergyTypes["electricity"];
var samples = manifest.Samples;
// The catalog is the metadata a page holds for its lifetime: loaded once, then every request reads data only.
var catalog = await reader.LoadCatalogAsync();
var invalid = catalog.Meters.Values.Where(m => m.IsVirtual && m.VirtualStatus != VirtualMeterStatus.Valid).Select(m => $"{m.Name}: {m.VirtualStatus}").ToList();
log.Write(invalid.Count == 0 ? "All virtual meters validate" : "Virtual meters that do not validate: " + string.Join("; ", invalid));
async Task<AnalysisResult> Cached(AnalysisRequest request)
{
await using var db = database.CreateDbContext();
return await reader.ReadAsync(db, catalog, request, CancellationToken.None);
}
var scenarios = new List<(string Id, string Description, Func<Task<string>> Run)>
{
("a", "100 selected meters (60 monthly, 25 daily, 10 live, 5 virtual), 10 years by month, catalog cached — the brief's target",
async () => Describe(await Cached(new AnalysisRequest(AnalysisScope.ForMeters(selection), tenYears) { Bucket = BucketSize.Month, MaxSeries = selection.Length }))),
("a'", "Same through the public API (catalog loaded by the request)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(selection), tenYears) { Bucket = BucketSize.Month, MaxSeries = selection.Length }))),
("b", "Portfolio, last 12 months by month (measures only, as the overview)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, last12) { Bucket = BucketSize.Month }))),
("b'", "Portfolio, last 12 months by month, with one series per meter",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, last12) { Bucket = BucketSize.Month, IncludeMeterSeries = true }))),
("b''", "Portfolio, last 12 months by month, compared with the previous year",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, last12)
{
Bucket = BucketSize.Month,
Comparison = new ComparisonRequest(ComparisonKind.PreviousYear),
}))),
("c", "Electricity type (≈420 meters), 10 years by month (measures only)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(electricity), tenYears) { Bucket = BucketSize.Month }))),
("c'", "Electricity type, 10 years by month, with one series per meter (the type page's table)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(electricity), tenYears) { Bucket = BucketSize.Month, IncludeMeterSeries = true }))),
("d", "One daily meter, 10 years by week (point limit raised to 600)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["dailyMeter"]), tenYears) { Bucket = BucketSize.Week, MaxPoints = 600 }))),
("d'", "One monthly meter, 10 years by week (point limit raised to 600)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["monthlyMeter"]), tenYears) { Bucket = BucketSize.Week, MaxPoints = 600 }))),
("d''", "One daily meter, the last 365 days by day",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["dailyMeter"]), lastYearByDay) { Bucket = BucketSize.Day }))),
("d'''", "One live (hourly) meter, the last 365 days by day",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["liveMeter"]), lastYearByDay) { Bucket = BucketSize.Day }))),
("e", "Portfolio bill, last 12 months by month, with categories",
async () => Describe(await costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, last12) { Bucket = BucketSize.Month, IncludeCategories = true }))),
("e'", "Portfolio bill, 10 years by month",
async () => Describe(await costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, tenYears) { Bucket = BucketSize.Month }))),
("f", "Virtual meter nested three levels (over 15 monthly, 10 daily, 2 differenced sources), 10 years by month",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["virtualNested"]), tenYears) { Bucket = BucketSize.Month }))),
("f'", "Virtual difference of a 40-meter sum and an 8-meter live sum, 10 years by month",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["virtualBigDifference"]), tenYears) { Bucket = BucketSize.Month }))),
("g", "1,000-meter selection with the chart limit (6 series): refused",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(all), last12) { Bucket = BucketSize.Month }))),
("g'", "Portfolio by day over 10 years: refused (point limit)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, tenYears) { Bucket = BucketSize.Day }))),
("g''", "Portfolio bill by day over 10 years: refused (point limit)",
async () => Describe(await costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, tenYears) { Bucket = BucketSize.Day }))),
("g'''", "Catalog load: 1,000 meters, tanks, links, rollup states; virtual definitions validated, totals classified",
async () => $"{(await reader.LoadCatalogAsync()).Meters.Count} meters"),
("g''''", "1,000-meter selection with the limit raised (as an export may), last 12 months by month",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(all), last12) { Bucket = BucketSize.Month, MaxSeries = int.MaxValue }))),
};
var loadDuring = HostLoadAsync(TimeSpan.FromSeconds(3));
var timings = new List<ScenarioTiming>();
foreach (var (id, description, run) in scenarios)
{
timings.Add(await MeasureAsync(id, description, run, settings, log));
}
// What the measured requests must hold, whatever they took.
var target = timings.Single(t => t.Id == "a");
Assert.True(target.Commands > 0, "The command counter saw no SQL for a request that reads data; Npgsql tracing did not reach it.");
foreach (var refused in timings.Where(t => t.Id.StartsWith('g') && t.Shape.StartsWith("refused", StringComparison.Ordinal)))
{
if (refused.Commands != 0)
{
Assert.Fail($"{refused.Id} was refused but sent {refused.Commands} SQL command(s): {string.Join(" / ", refused.Statements)}");
}
}
Assert.StartsWith("refused", timings.Single(t => t.Id == "g").Shape, StringComparison.Ordinal);
Assert.StartsWith("refused", timings.Single(t => t.Id == "g'").Shape, StringComparison.Ordinal);
Assert.StartsWith("refused", timings.Single(t => t.Id == "g''").Shape, StringComparison.Ordinal);
Assert.Contains($"{selection.Length} series", target.Shape, StringComparison.Ordinal);
var recomputes = await RecomputeTimingsAsync(database, manifest, log);
var plans = await PlansAsync(database, catalog, manifest, zone, firstMonth, currentMonth, today, log);
await using var connection = new NpgsqlConnection(database.ConnectionString);
await connection.OpenAsync();
var markdown = await WriteReportAsync(
settings, database, connection, prepared, timings, recomputes, plans, invalid, loadBefore, await loadDuring, catalog.Meters.Count);
log.Write($"Results written to {markdown}");
}
// ------------------------------------------------------------------------------------------------ measuring
private static async Task<ScenarioTiming> MeasureAsync(string id, string description, Func<Task<string>> run, PerfSettings settings, PerfLog log)
{
for (var i = 0; i < settings.Warmup; i++)
{
await run();
}
// One counted run, untimed: the listener is only attached while counting.
int commands;
IReadOnlyList<string> statements;
string shape;
double sql;
using (var counter = CommandCounter.Start())
{
shape = await run();
commands = counter.Count;
statements = counter.Statements;
sql = counter.Duration.TotalMilliseconds;
}
var times = new List<double>();
for (var i = 0; i < settings.Runs; i++)
{
var watch = Stopwatch.StartNew();
await run();
times.Add(watch.Elapsed.TotalMilliseconds);
}
var timing = new ScenarioTiming(id, description, times, commands, statements, shape) { SqlMilliseconds = sql };
log.Write($"({id}) median {F(timing.Median)} ms, p95 {F(timing.P95)} ms, {commands} SQL taking {F(sql)} ms — {shape}");
return timing;
}
private static string Describe(AnalysisResult result)
{
if (result.Refusal != AnalysisRefusal.None)
{
return $"refused ({result.Refusal})";
}
var values = result.Series.Concat(result.Measures).SelectMany(s => s.Values).ToList();
var statuses = values.GroupBy(v => v.Status).OrderBy(g => g.Key).Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Key} {g.Count()}"));
return string.Create(
CultureInfo.InvariantCulture,
$"{result.Series.Count} series · {result.Measures.Count} measures · {result.Plan.Buckets.Count} {result.Plan.Size} buckets · {string.Join(", ", statuses)}");
}
private static string Describe(CostAnalysis bill)
{
if (bill.Refusal != CostRefusal.None)
{
return $"refused ({bill.Refusal})";
}
return string.Create(
CultureInfo.InvariantCulture,
$"{bill.Plan.Buckets.Count} {bill.Plan.Size} buckets · total {bill.Total.Cost ?? double.NaN:N0} {bill.Currency} ({bill.Total.Status}) · {bill.Lines.Count} lines · {bill.EnergyTypes.Count} types · categories {(bill.Composition is null ? "no" : "yes")}");
}
/// <summary>
/// How long one meter's full recompute takes (D-57: it still runs per live reading): delete and rewrite its
/// consumption, diff its rollups and coverage — as <c>IngestionService</c> does after each reading.
/// </summary>
private static async Task<List<(string Meter, int Readings, ScenarioTiming Timing)>> RecomputeTimingsAsync(
PerfDatabase database, DatasetManifest manifest, PerfLog log)
{
var result = new List<(string, int, ScenarioTiming)>();
(string Name, int Id)[] meters =
[
("monthly counter", manifest.Samples["monthlyMeter"]),
("imported month labels", manifest.Samples["labelMeter"]),
("daily counter", manifest.Samples["dailyMeter"]),
("live meter (hourly for a year)", manifest.Samples["liveMeter"]),
("tank", manifest.Samples["tankMeter"]),
("virtual (purge + state only)", manifest.Samples["virtualNested"]),
];
await using var connection = new NpgsqlConnection(database.ConnectionString);
await connection.OpenAsync();
foreach (var (name, id) in meters)
{
var readings = Convert.ToInt32(
await PerfDatabase.ScalarAsync(connection, string.Create(CultureInfo.InvariantCulture, $"SELECT count(*) FROM reading WHERE meter_id = {id}")),
CultureInfo.InvariantCulture);
var times = new List<double>();
for (var i = 0; i < 4; i++)
{
await using var db = database.CreateDbContext();
await using var tx = await db.Database.BeginTransactionAsync();
var watch = Stopwatch.StartNew();
await PerfDatabase.Normalization(db, manifest).RecomputeMeterAsync(id, batchId: null);
await db.SaveChangesAsync();
await tx.CommitAsync();
if (i > 0)
{
times.Add(watch.Elapsed.TotalMilliseconds);
}
}
var timing = new ScenarioTiming(name, name, times, 0, [], string.Empty);
log.Write($"Recompute {name} ({readings} readings): median {F(timing.Median)} ms");
result.Add((name, readings, timing));
}
return result;
}
// ------------------------------------------------------------------------------------------------ query plans
/// <summary>
/// EXPLAIN (ANALYZE, BUFFERS) of the reader's queries (copied from <c>AnalysisQueries</c>) with the parameters request
/// (a) — and, for the portfolio and day cases, (b) and (d'') — would send.
/// </summary>
private static async Task<List<(string Title, string Sql, string Plan)>> PlansAsync(
PerfDatabase database, AnalysisCatalog catalog, DatasetManifest manifest, TimeZoneInfo zone,
DateOnly firstMonth, DateOnly currentMonth, DateOnly today, PerfLog log)
{
var plans = new List<(string, string, string)>();
await using var connection = new NpgsqlConnection(database.ConnectionString);
await connection.OpenAsync();
var leaves = catalog.PhysicalLeaves(manifest.Selection100).Order().ToArray();
var virtualSources = manifest.Selection100.Where(id => catalog.Find(id)?.IsVirtual == true)
.SelectMany(id => catalog.PhysicalLeaves([id])).ToHashSet();
var portfolio = catalog.Meters.Values.Where(m => !m.IsVirtual).Select(m => m.Id).Order().ToArray();
var now = manifest.Now.UtcDateTime;
DateTime Midnight(DateOnly day) => GapAttribution.LocalMidnight(day, zone).UtcDateTime;
const string month = """
SELECT r.meter_id, r.month, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags,
r.max_interval_end
FROM unnest(@ids, @froms, @tos) AS w(meter_id, from_month, to_month)
JOIN consumption_rollup_month r ON r.meter_id = w.meter_id AND r.month >= w.from_month AND r.month < w.to_month
""";
const string day = """
SELECT r.meter_id, r.day, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags,
r.max_interval_end, false AS after_now
FROM unnest(@ids, @froms, @tos) AS w(meter_id, from_day, to_day)
JOIN consumption_rollup r ON r.meter_id = w.meter_id AND r.day >= w.from_day AND r.day < w.to_day
UNION ALL
SELECT r.meter_id, r.day, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags,
r.max_interval_end, true AS after_now
FROM consumption_rollup r
WHERE r.meter_id = ANY(@after_ids) AND r.day >= @after_from AND r.day < @after_to AND r.max_interval_end > @now
""";
const string raw = "SELECT meter_id, time, amount, quality FROM consumption WHERE meter_id = ANY(@i0) AND time >= @f0 AND time < @t0";
const string windows = """
SELECT w.idx, count(*)::int, sum(c.amount),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 0), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 2), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 3), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality NOT IN (0, 2, 3)), 0)
FROM unnest(@ids, @froms, @tos) WITH ORDINALITY AS w(meter_id, from_time, to_time, idx)
JOIN consumption c ON c.meter_id = w.meter_id AND c.time >= w.from_time AND c.time < w.to_time
GROUP BY w.idx
""";
const string coverage = """
SELECT meter_id, span_from, span_to, resolution_class, divided_at_months, gap_reason, last_interval_start
FROM meter_coverage
WHERE meter_id = ANY(@ids)
ORDER BY meter_id, span_from
""";
const string opening = """
SELECT m.id, c.time
FROM unnest(@ids) AS m(id)
CROSS JOIN LATERAL (
SELECT r.flags FROM consumption_rollup r WHERE r.meter_id = m.id ORDER BY r.day LIMIT 1) f
CROSS JOIN LATERAL (
SELECT c.time FROM consumption c WHERE c.meter_id = m.id ORDER BY c.time LIMIT 1) c
WHERE (f.flags & @flag) <> 0
""";
const string recent = """
SELECT m.id, r.time
FROM unnest(@ids) AS m(id)
CROSS JOIN LATERAL (
SELECT time FROM reading WHERE meter_id = m.id ORDER BY time DESC LIMIT @count) r
""";
const string events = "SELECT meter_id, max(time) FROM meter_event WHERE meter_id = ANY(@ids) GROUP BY meter_id";
async Task PlanAsync(string title, string sql, params (string Name, object Value)[] parameters)
{
try
{
await using var command = new NpgsqlCommand("EXPLAIN (ANALYZE, BUFFERS, SETTINGS) " + sql, connection) { CommandTimeout = 0 };
foreach (var (name, value) in parameters)
{
command.Parameters.AddWithValue(name, value);
}
var lines = new List<string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.Add(reader.GetString(0));
}
plans.Add((title, sql, string.Join('\n', lines)));
}
catch (PostgresException ex)
{
plans.Add((title, sql, "EXPLAIN failed: " + ex.MessageText));
}
}
var dayFroms = leaves.Select(id => virtualSources.Contains(id) ? firstMonth : currentMonth).ToArray();
var afterTo = currentMonth.AddMonths(1);
log.Write("Capturing query plans…");
await PlanAsync($"(a) month rollups — {leaves.Length} physical leaves × {firstMonth:yyyy-MM} … {currentMonth.AddMonths(-1):yyyy-MM}", month,
("ids", leaves), ("froms", leaves.Select(_ => firstMonth).ToArray()), ("tos", leaves.Select(_ => currentMonth).ToArray()));
await PlanAsync($"(a) day rollups — the current month's complete days for every leaf, every day for virtual sources ({virtualSources.Count}), plus the after-now block", day,
("ids", leaves), ("froms", dayFroms), ("tos", leaves.Select(_ => today).ToArray()),
("after_ids", leaves), ("after_from", today.AddDays(1)), ("after_to", afterTo), ("now", now));
await PlanAsync($"(a) partial edge day — today ({today:yyyy-MM-dd}) from consumption", raw,
("i0", leaves), ("f0", Midnight(today)), ("t0", Midnight(today.AddDays(1))));
await PlanAsync("(a) coverage runs", coverage, ("ids", leaves));
await PlanAsync("(a) opening balances", opening, ("ids", leaves), ("flag", (int)RollupFlags.OpeningBalance));
await PlanAsync("(a) freshness — the latest readings per meter (reading is compressed after 30 days)", recent, ("ids", leaves), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync("(a) freshness — the latest event per meter", events, ("ids", leaves));
await PlanAsync($"(b) freshness — the latest readings of every physical meter ({portfolio.Length}), as a portfolio request loads them", recent,
("ids", portfolio), ("count", FreshnessRules.RecentReadingCount));
// Not the reader's SQL: the same statement with a constant lower time bound, to show what chunk exclusion saves.
var daily = manifest.Samples["dailyMeter"];
const string recentBounded = """
SELECT m.id, r.time
FROM unnest(@ids) AS m(id)
CROSS JOIN LATERAL (
SELECT time FROM reading WHERE meter_id = m.id AND time >= @since ORDER BY time DESC LIMIT @count) r
""";
await PlanAsync("(d) freshness — one daily meter, as every single-meter request sends it", recent,
("ids", new[] { daily }), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync("(comparison, not the reader's SQL) the same for one daily meter with a constant 90-day lower bound", recentBounded,
("ids", new[] { daily }), ("since", now.AddDays(-90)), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync($"(b) month rollups — portfolio, {portfolio.Length} physical meters × the 11 complete months of the last 12", month,
("ids", portfolio), ("froms", portfolio.Select(_ => currentMonth.AddMonths(-11)).ToArray()), ("tos", portfolio.Select(_ => currentMonth).ToArray()));
var yearAgo = today.AddYears(-1);
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(now, zone);
var yearAgoCut = TimeZoneInfo.ConvertTimeToUtc(yearAgo.ToDateTime(TimeOnly.FromDateTime(nowLocal)), zone);
var single = manifest.Samples["monthlyMeter"];
await PlanAsync("(b'') window sums — one meter's two partial days (today, and its image a year ago): the small case", windows,
("ids", new[] { single, single }),
("froms", new[] { Midnight(today), Midnight(yearAgo) }),
("tos", new[] { now, yearAgoCut }));
await PlanAsync(
$"(b'') window sums — stress case, not what (b'') sends (see its SQL list): today's partial day and its image a year ago for all {portfolio.Length} meters",
windows,
("ids", portfolio.Concat(portfolio).ToArray()),
("froms", portfolio.Select(_ => Midnight(today)).Concat(portfolio.Select(_ => Midnight(yearAgo))).ToArray()),
("tos", portfolio.Select(_ => now).Concat(portfolio.Select(_ => yearAgoCut)).ToArray()));
await PlanAsync("(d'') day rollups — one daily meter, the last 365 days", day,
("ids", new[] { daily }), ("froms", new[] { today.AddDays(-364) }), ("tos", new[] { today }),
("after_ids", new[] { daily }), ("after_from", today.AddDays(1)), ("after_to", today.AddDays(1)), ("now", now));
return plans;
}
// ------------------------------------------------------------------------------------------------ report
private static async Task<string> WriteReportAsync(
PerfSettings settings,
PerfDatabase database,
NpgsqlConnection connection,
PreparedDatabase prepared,
List<ScenarioTiming> timings,
List<(string Meter, int Readings, ScenarioTiming Timing)> recomputes,
List<(string Title, string Sql, string Plan)> plans,
List<string> invalidVirtuals,
string loadBefore,
string loadDuring,
int catalogMeters)
{
var manifest = prepared.Manifest;
var md = new StringBuilder();
md.AppendLine(CultureInfo.InvariantCulture, $"# Analysis reader performance — {settings.Label}");
md.AppendLine();
md.AppendLine(CultureInfo.InvariantCulture, $"Measured {DateTimeOffset.Now:yyyy-MM-dd HH:mm zzz}. Brief §9.10 / D-56. Dataset \"now\": {manifest.Now:O} ({manifest.Zone}).");
md.AppendLine(CultureInfo.InvariantCulture, $"{settings.Warmup} warm-up run(s), then {settings.Runs} measured runs per scenario; the SQL count comes from one extra untimed run.");
if (settings.Note is { } note)
{
md.AppendLine().AppendLine(CultureInfo.InvariantCulture, $"> Note: {note}");
}
md.AppendLine();
md.AppendLine("## Machine");
md.AppendLine();
var machine = Machine().Select(m => (IReadOnlyList<string>)[m.Name, m.Value]).ToList();
machine.Add(["Host load before the run", loadBefore]);
machine.Add(["Host load while measuring", loadDuring]);
machine.Add(["Database", database.Origin]);
foreach (var (name, value) in await DatabaseAsync(connection))
{
machine.Add([name, value]);
}
Table(md, ["", "Value"], machine);
md.AppendLine("## Dataset");
md.AppendLine();
md.AppendLine(CultureInfo.InvariantCulture,
$"{manifest.MeterCount} meters (scale {F(manifest.Scale, 2)}): {string.Join(", ", manifest.Groups.Select(g => $"{g.Value.Length} {g.Key}"))}. " +
$"{manifest.EnergyTypes.Count} energy types, {manifest.Links} links, {manifest.Tariffs} tariff rows, {manifest.CategoryMembers} category members, {manifest.ManualCosts} manual costs, {N(manifest.Events)} events.");
md.AppendLine();
md.AppendLine("Monthly counters mix readings at local midnight on the 1st, imported month labels (00:00 UTC, flagged) and irregular manual readings " +
"(divided at month ends); some have no install date (opening balance), start late, retire in 2022, or have a register swap. Daily counters read at 06:xx, " +
"live meters hourly for the last year (monthly before), generation counters in the evening; the electricity type and two extra sites have grid import/export roles. " +
"Twenty virtual meters are sums and differences, nested up to three levels. The cost setup has yearly unit-price changes (a mid-2022 spike for electricity and gas), " +
"standing charges, feed-in, five meter prices on linked subsections, four categories (one overlapping) and manual costs.");
md.AppendLine();
Table(md, ["Table", "Rows", "Size", "Chunks"], (await TablesAsync(connection)).Select(t => (IReadOnlyList<string>)[t.Table, N(t.Rows), t.Size, t.Note]));
md.AppendLine("## Loading and rebuild");
md.AppendLine();
var steps = new List<IReadOnlyList<string>>();
steps.Add(prepared.Loaded
? ["Raw load (binary COPY of readings, plus configuration)", F(manifest.LoadSeconds) + " s", $"{N(manifest.Readings)} readings; COPY itself {F(manifest.CopySeconds)} s"]
: ["Raw load", "reused", $"{N(manifest.Readings)} readings loaded earlier (COPY {F(manifest.CopySeconds)} s, total {F(manifest.LoadSeconds)} s)"]);
if (prepared.Rebuild is { } rebuild)
{
steps.Add([
"Startup rebuild (NormalizationUpgrade: consumption, rollups, coverage, state)",
F(rebuild.Seconds) + " s",
$"{rebuild.Rebuilt} of {rebuild.Meters} meters, {F(rebuild.Meters / rebuild.Seconds)} meters/s{(rebuild.MeasuredInThisRun ? string.Empty : " (measured when the dataset was loaded)")}",
]);
}
steps.Add(prepared.CompressSeconds is { } compress
? ["Compression of raw chunks older than 30 days", F(compress) + " s", $"{prepared.CompressedChunks} chunks"]
: ["Compression of raw chunks older than 30 days", "—", "already compressed"]);
Table(md, ["Step", "Time", "Detail"], steps);
md.AppendLine("One meter's full recompute (what every ingested reading triggers, D-57), median of 3 after a warm-up:");
md.AppendLine();
Table(md, ["Meter", "Readings", "Median ms", "Min ms", "Max ms"],
recomputes.Select(r => (IReadOnlyList<string>)[r.Meter, N(r.Readings), F(r.Timing.Median), F(r.Timing.Min), F(r.Timing.Max)]));
md.AppendLine("## Reader and cost timings");
md.AppendLine();
md.AppendLine(CultureInfo.InvariantCulture, $"Catalog: {catalogMeters} meters. {(invalidVirtuals.Count == 0 ? "All virtual meters validate." : "Virtual meters that do not validate: " + string.Join("; ", invalidVirtuals))}");
md.AppendLine();
md.AppendLine("*SQL* is the number of commands one extra, untimed run sent, and *SQL ms* their summed duration (Npgsql activity: execution " +
"until the reader is closed, so row materialization is included); the rest of the median is in-process work.");
md.AppendLine();
Table(md, ["", "Request", "Median ms", "p95 ms", "Min ms", "Max ms", "SQL", "SQL ms", "Result"],
timings.Select(t => (IReadOnlyList<string>)[
t.Id, t.Description, F(t.Median), F(t.P95), F(t.Min), F(t.Max), t.Commands.ToString(CultureInfo.InvariantCulture), F(t.SqlMilliseconds), t.Shape]));
var target = timings.Single(t => t.Id == "a");
md.AppendLine(CultureInfo.InvariantCulture,
$"**Target (a):** median {F(target.Median)} ms, p95 {F(target.P95)} ms against {F(TargetMilliseconds, 0)} ms — {(target.P95 < TargetMilliseconds ? "met" : target.Median < TargetMilliseconds ? "met at the median, not at p95" : "not met")}.");
md.AppendLine();
md.AppendLine("**Limits:** the refused requests (g, g', g'') sent " +
string.Join(", ", timings.Where(t => t.Shape.StartsWith("refused", StringComparison.Ordinal)).Select(t => $"{t.Id}: {t.Commands}")) +
" SQL commands — the limits are checked before any SQL runs.");
md.AppendLine();
md.AppendLine("### SQL sent per request");
md.AppendLine();
foreach (var timing in timings.Where(t => t.Commands > 0))
{
md.AppendLine(CultureInfo.InvariantCulture, $"<details><summary>({timing.Id}) {timing.Commands} commands, {F(timing.SqlMilliseconds)} ms</summary>");
md.AppendLine();
md.AppendLine("```sql");
foreach (var statement in timing.Statements)
{
md.AppendLine(statement);
}
md.AppendLine("```");
md.AppendLine("</details>");
md.AppendLine();
}
md.AppendLine("## Query plans");
md.AppendLine();
md.AppendLine("EXPLAIN (ANALYZE, BUFFERS, SETTINGS) of the reader's statements (copied from `AnalysisQueries`) with the parameters of the request named.");
md.AppendLine();
foreach (var (title, sql, plan) in plans)
{
md.AppendLine(CultureInfo.InvariantCulture, $"### {title}");
md.AppendLine();
md.AppendLine("```sql").AppendLine(sql.Trim()).AppendLine("```");
md.AppendLine("```").AppendLine(plan).AppendLine("```");
md.AppendLine();
}
Directory.CreateDirectory(settings.OutputDirectory);
var path = Path.Combine(settings.OutputDirectory, $"results-{settings.Label}.md");
await File.WriteAllTextAsync(path, md.ToString());
await File.WriteAllTextAsync(
Path.Combine(settings.OutputDirectory, $"results-{settings.Label}.json"),
JsonSerializer.Serialize(
new { settings.Label, manifest.Now, prepared.Rebuild, Timings = timings.Select(t => new { t.Id, t.Description, t.Median, t.P95, t.Min, t.Max, t.Commands, t.SqlMilliseconds, t.Shape, t.Milliseconds }) },
DatasetManifest.Json));
return path;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System.Globalization;
using System.Text.Json;
using Npgsql;
using Xunit.Abstractions;
namespace MeterVault.Integration.Tests.Performance;
/// <summary>
/// Loads the synthetic raw dataset into a database an app has already migrated — the pre-rework app (c0f52db) or the
/// current one — for the page-level before/after comparison (brief §9.10). Nothing derived is written: the next start
/// of that app rebuilds consumption (and, in the new app, rollups and coverage) itself, because the load clears the
/// stored normalization revision. Writes <c>manifest-&lt;label&gt;.json</c> with the meter and type ids the page
/// timings address.
/// </summary>
/// <remarks>
/// <c>METERVAULT_PERF=1 METERVAULT_PERF_LOAD_DB="Host=…;Port=…;Database=metervault;Username=metervault;Password=metervault"
/// dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Performance.SyntheticLoadTests"</c>. The dataset ends at
/// <c>METERVAULT_PERF_NOW</c>, or at the current time, so the pages see recent live data.
/// </remarks>
[Trait("Category", "Performance")]
public sealed class SyntheticLoadTests(ITestOutputHelper output)
{
[PerfLoadFact]
public async Task Load_the_synthetic_dataset_into_an_app_migrated_database()
{
var settings = PerfSettings.Current;
using var log = new PerfLog(settings, "load", output);
var target = settings.LoadDatabase!;
await using (var connection = new NpgsqlConnection(target))
{
await connection.OpenAsync();
var migrated = await PerfDatabase.ScalarAsync(connection, "SELECT to_regclass('meter') IS NOT NULL AND to_regclass('app_setting') IS NOT NULL");
Assert.True(migrated is true, "The target database is not migrated: start the app against it once first.");
var schema = await PerfDatabase.ScalarAsync(connection, "SELECT to_regclass('consumption_rollup') IS NOT NULL") is true ? "analysis rework" : "pre-rework (c0f52db)";
log.Write($"Target schema: {schema}");
}
// The current minute, so the hourly live meters end just before the pages are timed.
var now = settings.Now ?? DateTimeOffset.UtcNow.AddSeconds(-DateTimeOffset.UtcNow.Second);
var zone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var manifest = await SyntheticDataset.LoadAsync(target, now, zone, settings.Scale, log.Write);
Directory.CreateDirectory(settings.OutputDirectory);
var path = Path.Combine(settings.OutputDirectory, $"manifest-{settings.Label}.json");
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(manifest, DatasetManifest.Json));
log.Write(string.Create(CultureInfo.InvariantCulture, $"Loaded {manifest.MeterCount} meters and {manifest.Readings:N0} readings; manifest {path}"));
}
}
@@ -0,0 +1,127 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Import;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Reconciliation;
/// <summary>
/// Coverage runs (D-13) of the seeded reference sheets, in the instance timezone. These are the shapes
/// the analysis pages meet first: monthly sheets that resolve months exactly, a burner read once in twelve
/// years before its monthly rows begin, and a tank whose deliveries go back to 1997 but whose draw is only
/// known from the first dipstick in 2022.
/// </summary>
public sealed class CoverageOfFixturesTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static IReadOnlyList<CoverageRun> Coverage(StagedImport staged, MeterConfig config) =>
CoverageBuilder.Build(
NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = config,
Readings = staged.Readings.Where(r => r.MeterId == config.MeterId).ToList(),
Events = staged.Events.Where(e => e.MeterId == config.MeterId).ToList(),
TimeZone = Berlin,
}),
Berlin);
[Theory]
[InlineData(ReferenceProfiles.Haus, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Netz, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Auto, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Solar1, MeterMode.GenerationCounter)]
[InlineData(ReferenceProfiles.Solar2, MeterMode.GenerationCounter)]
public void Every_electricity_meter_is_one_month_run_divided_at_months(int meterId, MeterMode mode)
{
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
var readings = staged.Readings.Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToList();
var run = Assert.Single(Coverage(staged, new MeterConfig { MeterId = meterId, Mode = mode, Unit = "kWh" }));
Assert.Equal(GapAttribution.LabelMonthStart(readings[0], Berlin), run.From);
Assert.Equal(GapAttribution.EffectiveTime(readings[^1], Berlin), run.To);
Assert.Equal(GapAttribution.LabelMonthStart(readings[^1], Berlin), run.LastIntervalStart);
Assert.Equal(ResolutionClass.Month, run.Resolution);
Assert.True(run.DividedAtMonths);
Assert.False(run.IsGap);
}
[Fact]
public void Capping_the_electricity_sheet_inside_its_last_month_gives_up_exactly_that_month()
{
// A-04: the sheet's last row describes its whole month. A reader whose now falls in that month
// leaves the row out of actuals and its month uncovered, and keeps every month before it.
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
var readings = staged.Readings.Where(r => r.MeterId == ReferenceProfiles.Haus).OrderBy(r => r.Time).ToList();
var lastMonth = GapAttribution.LabelMonthStart(readings[^1], Berlin);
var stored = Assert.Single(Coverage(staged, new MeterConfig { MeterId = ReferenceProfiles.Haus, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }));
var capped = Assert.Single(CoverageRuns.CapAt([stored], lastMonth.AddDays(10), Berlin));
Assert.Equal(stored.From, capped.From);
Assert.Equal(lastMonth, capped.To);
}
[Fact]
public void The_water_meter_is_covered_across_its_swap()
{
var staged = Stage(ReferenceProfiles.Water(), Water);
var run = Assert.Single(Coverage(staged, new MeterConfig
{
MeterId = ReferenceProfiles.Wasser, Mode = MeterMode.CumulativeCounter, Unit = "m3", InitialBaseline = 820,
}));
Assert.Equal(ResolutionClass.Month, run.Resolution);
Assert.True(run.DividedAtMonths);
}
[Fact]
public void The_seeded_burner_starts_with_its_twelve_year_interval_as_its_own_coarse_run()
{
var staged = Stage(ReferenceProfiles.HeatingOil(), Oil);
var readings = staged.Readings.Where(r => r.MeterId == ReferenceProfiles.Burner).OrderBy(r => r.Time).ToList();
var runs = Coverage(staged, new MeterConfig { MeterId = ReferenceProfiles.Burner, Mode = MeterMode.RuntimeCounter, Unit = "h" });
// 18.10.2010 → 14.10.2022: the first reading is an opening balance, the next closes twelve years.
Assert.Equal(
new CoverageRun(Utc(2010, 10, 18), Utc(2022, 10, 14), ResolutionClass.Coarse, DividedAtMonths: false, LastIntervalStart: Utc(2010, 10, 18)),
runs[0]);
Assert.Equal(readings[^1].Time, runs[^1].To);
Assert.Equal(ResolutionClass.Month, runs[^1].Resolution);
Assert.DoesNotContain(runs, r => r.IsGap);
for (var i = 1; i < runs.Count; i++)
{
Assert.Equal(runs[i - 1].To, runs[i].From);
}
}
[Fact]
public void The_seeded_tank_is_covered_from_its_first_dipstick_not_its_first_delivery()
{
var staged = Stage(ReferenceProfiles.HeatingOil(), Oil);
var levels = staged.Events
.Where(e => e.MeterId == ReferenceProfiles.OilTank && e.EventType == MeterEventType.TankLevel)
.OrderBy(e => e.Time)
.ToList();
Assert.Contains(staged.Events, e => e.MeterId == ReferenceProfiles.OilTank && e.EventType == MeterEventType.Delivery && e.Time.Year == 1997);
var runs = Coverage(staged, new MeterConfig
{
MeterId = ReferenceProfiles.OilTank,
Mode = MeterMode.ConsumableBalance,
Unit = "L",
Tank = new TankConfig { Capacity = 7000, Calibration = new CalibrationCurve(ReferenceProfiles.OilLitresPerCm) },
});
Assert.Equal(Utc(2022, 9, 8), runs[0].From);
Assert.Equal(levels[^1].Time, runs[^1].To);
Assert.DoesNotContain(runs, r => r.IsGap);
}
private static DateTimeOffset Utc(int year, int month, int day) => new(year, month, day, 0, 0, 0, TimeSpan.Zero);
}
@@ -1,3 +1,5 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Import;
@@ -11,6 +13,9 @@ namespace MeterVault.Integration.Tests.Reconciliation;
/// </summary>
public sealed class ElectricityReconciliationTests
{
/// <summary>The virtual Netz Einsparung meter; any id no reference profile uses.</summary>
private const int NetzEinsparung = 100;
private static MeterConfig Cumulative(int id) =>
new() { MeterId = id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
@@ -39,34 +44,65 @@ public sealed class ElectricityReconciliationTests
[Fact]
public void Netz_einsparung_virtual_matches_the_sheet()
{
// Evaluated the way the app reads a virtual meter (D-27, D-29): per month bucket, strict about coverage.
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
var haus = Normalize(staged, Cumulative(ReferenceProfiles.Haus));
var netz = Normalize(staged, Cumulative(ReferenceProfiles.Netz));
var months = MonthBuckets(haus.Concat(netz));
var engine = NormalizationEngine.CreateDefault();
var virtualContext = new NormalizationContext
{
Meter = new MeterConfig
{
MeterId = 100,
Mode = MeterMode.Virtual,
Unit = "kWh",
Virtual = new VirtualSpec
{
Expression = $"m{ReferenceProfiles.Haus} - m{ReferenceProfiles.Netz}",
ReferencedMeterIds = [ReferenceProfiles.Haus, ReferenceProfiles.Netz],
},
},
ReferencedSeries = new Dictionary<int, IReadOnlyList<Consumption>>
{
[ReferenceProfiles.Haus] = haus,
[ReferenceProfiles.Netz] = netz,
},
};
var evaluation = VirtualEvaluator.Evaluate(
NetzEinsparung,
Formula.Parse($"m{ReferenceProfiles.Haus} - m{ReferenceProfiles.Netz}"),
QuantityKind.Consumption,
months,
[MonthlySource(ReferenceProfiles.Haus, haus), MonthlySource(ReferenceProfiles.Netz, netz)]);
var computed = ByMonth(engine.Normalize(virtualContext));
var computed = months.Zip(evaluation.Values)
.Where(m => m.Second.Status == BucketStatus.Available)
.ToDictionary(m => m.First.FirstDay, m => m.Second.Value!.Value);
var oracle = OracleByMonth(ReadRows(Electricity), dateColumn: 0, valueColumn: 13, firstDataRow: 1); // Netz Einsparung
AssertReconciles(computed, oracle, tolerance: 1.0, "Netz Einsparung", minMatches: 20);
Assert.True(evaluation.IsAdditive);
}
/// <summary>Contiguous UTC month buckets from the first to the last month any row falls in.</summary>
private static List<AnalysisBucket> MonthBuckets(IEnumerable<Consumption> rows)
{
var keys = rows.Select(r => MonthKey(r.Time)).ToList();
var buckets = new List<AnalysisBucket>();
for (var month = keys.Min(); month <= keys.Max(); month = month.AddMonths(1))
{
buckets.Add(new AnalysisBucket(month, month.AddMonths(1), Utc(month), Utc(month.AddMonths(1)), BucketSize.Month));
}
return buckets;
}
/// <summary>
/// A meter's normalized rows as the reader hands them to the evaluator: day totals, with every day of a month
/// that has a row covered at month resolution — an imported monthly table covers its labelled months (D-10), and
/// each interval lies inside its month, so the run is divided at months (A-02).
/// </summary>
private static VirtualSource MonthlySource(int meterId, IReadOnlyList<Consumption> rows)
{
var days = new Dictionary<DateOnly, SourceDay>();
foreach (var month in rows.Select(r => MonthKey(r.Time)).Distinct())
{
for (var day = month; day < month.AddMonths(1); day = day.AddDays(1))
{
days[day] = new SourceDay(0, true, ResolutionClass.Month, Provenance.Imported, DividedAtMonths: true);
}
}
foreach (var row in rows)
{
var day = DateOnly.FromDateTime(row.Time.UtcDateTime);
days[day] = days[day] with { Amount = days[day].Amount + row.Amount };
}
return new VirtualSource(meterId, days);
}
private static DateTimeOffset Utc(DateOnly day) => new(day.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
}
@@ -108,9 +108,14 @@ internal static class ReconciliationSupport
public static Dictionary<DateOnly, double> ByMonth(IReadOnlyList<Consumption> series) =>
series.GroupBy(c => MonthKey(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
/// <summary>Consumption keyed by exact reading date (oil rows are event-dated, sometimes two per month).</summary>
/// <summary>
/// Consumption keyed by exact reading date (oil rows are event-dated, sometimes two per month). The
/// date is the one the closing reading describes (<see cref="Consumption.IntervalEnd"/>), not the
/// row's stamp: a day-dated row sits at 00:00 UTC, which in these UTC runs is a local midnight, so its
/// row is stamped one second earlier, inside the day it closes (D-11).
/// </summary>
public static Dictionary<DateOnly, double> ByDate(IReadOnlyList<Consumption> series) =>
series.GroupBy(c => DateOnly.FromDateTime(c.Time.UtcDateTime))
series.GroupBy(c => DateOnly.FromDateTime((c.IntervalEnd ?? c.Time).UtcDateTime))
.ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
/// <summary>
+37
View File
@@ -14,6 +14,43 @@ public sealed class SchemaTests(TimescaleFixture fx)
Assert.Contains(applied, m => m.EndsWith("InitialSchema", StringComparison.Ordinal));
Assert.Contains(applied, m => m.EndsWith("TimescaleHypertables", StringComparison.Ordinal));
Assert.Contains(applied, m => m.EndsWith("AnalysisRollups", StringComparison.Ordinal));
}
[Fact]
public async Task The_continuous_aggregates_and_their_refresh_jobs_are_gone()
{
// D-17: nothing read them, they were Berlin-only and stale; the analysis tables replace them.
await using var ctx = fx.CreateContext();
var conn = ctx.Database.GetDbConnection();
await conn.OpenAsync();
Assert.Empty(await QueryStringsAsync(conn,
"SELECT view_name::text FROM timescaledb_information.continuous_aggregates;"));
Assert.Empty(await QueryStringsAsync(conn,
"SELECT proc_name::text FROM timescaledb_information.jobs WHERE proc_name = 'policy_refresh_continuous_aggregate';"));
Assert.Empty(await QueryStringsAsync(conn,
"SELECT relname::text FROM pg_class WHERE relname IN ('consumption_daily', 'consumption_monthly', 'consumption_yearly');"));
}
[Fact]
public async Task The_analysis_tables_are_plain_tables_that_go_with_their_meter()
{
// D-12: plain tables (the hypertable list above stays reading + consumption), each tied to its meter by a
// cascading foreign key, so deleting a meter — or wiping all of them — never trips over them.
await using var ctx = fx.CreateContext();
var conn = ctx.Database.GetDbConnection();
await conn.OpenAsync();
var cascading = await QueryStringsAsync(conn,
"SELECT tc.table_name::text FROM information_schema.table_constraints tc " +
"JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name " +
"JOIN information_schema.constraint_column_usage cu ON cu.constraint_name = tc.constraint_name " +
"WHERE tc.constraint_type = 'FOREIGN KEY' AND rc.delete_rule = 'CASCADE' AND cu.table_name = 'meter' " +
"AND tc.table_name IN ('consumption_rollup', 'consumption_rollup_month', 'meter_coverage', 'meter_rollup_state') " +
"ORDER BY 1;");
Assert.Equal(["consumption_rollup", "consumption_rollup_month", "meter_coverage", "meter_rollup_state"], cascading);
}
[Fact]
@@ -0,0 +1,250 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
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 Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Specialized;
/// <summary>
/// The Tanks &amp; consumables read model (brief §7.5, D-54) on the shared readers, on the frozen clock of
/// <see cref="CostSandbox.Now"/> (19 September 2026): the last dipstick kept apart from the contents estimated now; a
/// period that is over shows its own end's contents, not today's; deliveries only from the period; usage from the
/// analysis reader; burner runtime and the burn rate; and a cost that is unknown stays unknown, never 0 (D-38, A-16).
/// </summary>
/// <remarks>The view reads every consumable meter, so every test starts from — and leaves — an instance without meters.</remarks>
[Collection("Timescale")]
public sealed class ConsumableServiceTests(TimescaleFixture fx) : IAsyncLifetime
{
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await SolarServiceTests.ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await SolarServiceTests.ClearDataAsync(db);
}
[Fact]
public async Task Usage_is_up_to_now_and_an_unpriced_cost_is_not_a_zero()
{
// A dipstick dated after now is no actual: June 200, July 1,800 + 1,000 2,600 = 200, not the 25 September draw.
// The tank has no tariff: its cost is "not priced", with no value — never a confident 0 €.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("L");
var tank = await TankAsync(box, type, 3000, installedAt: D(2026, 5, 1));
await EventsAsync(
Level(tank, Midnight(2026, 6, 1), 2000),
Level(tank, Midnight(2026, 7, 1), 1800),
Delivery(tank, Midnight(2026, 7, 15), 1000),
Level(tank, Midnight(2026, 8, 1), 2600),
Level(tank, Midnight(2026, 9, 25), 2500));
await box.RecomputeAsync(tank);
var unpriced = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 9, 30)));
Assert.Equal(400, unpriced.Usage!.Total.Value!.Value, 6);
Assert.Equal("L", unpriced.Usage.Unit);
Assert.Equal((2600d, 0d), (unpriced.EstimatedNow!.Volume, unpriced.EstimatedNow.DeliveredSince));
Assert.Equal(Midnight(2026, 8, 1), unpriced.LastDipstick!.Time);
Assert.Equal(2600d / 3000, unpriced.FillFraction!.Value, 6);
Assert.False(unpriced.PeriodEndsBeforeNow);
Assert.Null(unpriced.AtPeriodEnd);
var cost = unpriced.Cost!.Total;
Assert.Equal(CostStatus.NotPriced, cost.Status);
Assert.Null(cost.Cost);
Assert.Equal(CostStatus.NotPriced, Assert.Single(cost.MissingPrices).Reason);
Assert.Contains(unpriced.Cost.Attention, a => a.Kind == CostAttentionKind.MissingPrice);
await box.TypePriceAsync(type, 1.50, D(2026, 1, 1), unit: "EUR/L");
var priced = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 9, 30)));
Assert.Equal((400 * 1.50, CostStatus.Priced), (Math.Round(priced.Cost!.Total.Cost!.Value, 6), priced.Cost.Total.Status));
}
[Fact]
public async Task A_tank_dipped_every_few_months_has_its_cost_and_an_unplaceable_one_is_unknown()
{
// A-16: dipsticks on 17 January, 3 April, 12 September and 20 December at 1.10 €/L. No month can take a draw, but
// the year holds all three whole and one price covers it: 2,100 L cost 2,310 €. The first half cuts the April
// September draw: its cost cannot be placed, and it has no value at all rather than 0 €.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("L");
var tank = await TankAsync(box, type, 7000);
await EventsAsync(
Level(tank, Midnight(2025, 1, 17), 5000),
Level(tank, Midnight(2025, 4, 3), 4200),
Level(tank, Midnight(2025, 9, 12), 3600),
Level(tank, Midnight(2025, 12, 20), 2900));
await box.RecomputeAsync(tank);
await box.TypePriceAsync(type, 1.10, D(2025, 1, 1), unit: "EUR/L");
var year = await TankAsync(tank, Year(2025));
Assert.Equal(2100, year.Usage!.Total.Value!.Value, 6);
Assert.Equal((2310d, CostStatus.Priced), (Math.Round(year.Cost!.Total.Cost!.Value, 6), year.Cost.Total.Status));
var half = await TankAsync(tank, Custom(D(2025, 1, 1), D(2025, 6, 30)));
Assert.Null(half.Cost!.Total.Cost);
Assert.Equal(BucketStatus.Unresolved, half.Cost.Total.Availability);
}
[Fact]
public async Task A_period_that_is_over_shows_its_own_end_not_today_and_only_its_deliveries()
{
// 10 January 3,000 L; 1 February 2,000 L delivered; 1 March 4,000 L; 1 August 2,500 L; 20 August 1,000 L delivered.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("L");
var tank = await TankAsync(box, type, 7000);
await EventsAsync(
Level(tank, Midnight(2026, 1, 10), 3000),
Delivery(tank, Midnight(2026, 2, 1), 2000),
Level(tank, Midnight(2026, 3, 1), 4000),
Level(tank, Midnight(2026, 8, 1), 2500),
Delivery(tank, Midnight(2026, 8, 20), 1000));
await box.RecomputeAsync(tank);
// January February is over: its end holds the January dipstick plus the February delivery, and only that
// delivery is listed. "Now" is still the August dipstick plus the August delivery, apart from it.
var winter = await TankAsync(tank, Custom(D(2026, 1, 1), D(2026, 2, 28)));
Assert.True(winter.PeriodEndsBeforeNow);
Assert.Equal((5000d, 2000d, 1), (winter.AtPeriodEnd!.Volume, winter.AtPeriodEnd.DeliveredSince, winter.AtPeriodEnd.DeliveriesSince));
Assert.Equal(Midnight(2026, 1, 10), winter.AtPeriodEnd.Dipstick.Time);
Assert.Equal([Midnight(2026, 2, 1)], winter.Deliveries.Select(d => d.Time));
Assert.Equal(2000, winter.DeliveredInPeriod, 6);
Assert.Equal((3500d, Midnight(2026, 8, 1)), (winter.EstimatedNow!.Volume, winter.EstimatedNow.Dipstick.Time));
Assert.Equal(Midnight(2026, 8, 1), winter.LastDipstick!.Time);
// A period ending the day before a dipstick does not see it: the end is exclusive (D-03).
var february = await TankAsync(tank, Custom(D(2026, 2, 1), D(2026, 2, 28)));
Assert.Equal(Midnight(2026, 1, 10), february.AtPeriodEnd!.Dipstick.Time);
// Before the first dipstick there is no contents to show — not a 0 L tank.
var december = await TankAsync(tank, Custom(D(2025, 12, 1), D(2025, 12, 31)));
Assert.True(december.PeriodEndsBeforeNow);
Assert.Null(december.AtPeriodEnd);
Assert.Empty(december.Deliveries);
// A period up to now has no separate end: its deliveries are both, newest first.
var year = await TankAsync(tank, Preset(PeriodPreset.YearToDate));
Assert.False(year.PeriodEndsBeforeNow);
Assert.Null(year.AtPeriodEnd);
Assert.Equal([Midnight(2026, 8, 20), Midnight(2026, 2, 1)], year.Deliveries.Select(d => d.Time));
}
[Fact]
public async Task The_forecast_is_a_projection_and_hidden_once_the_dipstick_is_older_than_60_days()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("L");
var fresh = await TankAsync(box, type, 7000);
await EventsAsync(Level(fresh, Midnight(2026, 6, 1), 3000), Level(fresh, Midnight(2026, 9, 1), 2100));
var stale = await TankAsync(box, type, 7000);
await EventsAsync(Level(stale, Midnight(2026, 4, 1), 3000), Level(stale, Midnight(2026, 7, 1), 2100));
await box.RecomputeAsync(fresh, stale);
var analysis = await Service().GetAsync(new ConsumableRequest(Preset(PeriodPreset.Last12Months)));
// 900 L over the 92 days from 1 June to 1 September: 2,100 L last 214.7 days from 1 September.
var projected = analysis.Tanks.Single(t => t.MeterId == fresh).Forecast;
Assert.Equal(TankForecastState.Projected, projected.State);
Assert.Equal(92, projected.BasisDays);
Assert.Equal(900d / 92, projected.PerDay!.Value, 6);
Assert.Equal(D(2027, 4, 3), projected.EmptyOn);
Assert.Equal(18, projected.DipstickAgeDays);
var hidden = analysis.Tanks.Single(t => t.MeterId == stale).Forecast;
Assert.Equal(TankForecastState.DipstickTooOld, hidden.State);
Assert.Null(hidden.EmptyOn);
Assert.Equal(80, hidden.DipstickAgeDays);
}
[Fact]
public async Task Burner_runtime_and_the_burn_rate_follow_the_period()
{
// The burner of the tank's energy type ran 100 h in June and 200 h in July; the tank lost 300 L and 400 L. The
// empirical rate is 700 L ÷ 300 h. A tank set to a fixed nozzle rate shows that rate instead.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("L");
var tank = await TankAsync(box, type, 7000);
await EventsAsync(Level(tank, Midnight(2026, 6, 1), 5000), Level(tank, Midnight(2026, 7, 1), 4700), Level(tank, Midnight(2026, 8, 1), 4300));
await box.RecomputeAsync(tank);
var burner = await box.MeterAsync(type, MeterMode.RuntimeCounter, "h", D(2026, 6, 1));
await box.MonthlyReadingsAsync(burner, D(2026, 6, 1), 100, 200);
var summer = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 7, 31)));
var runtime = Assert.Single(summer.Runtime);
Assert.Equal(burner, runtime.MeterId);
Assert.Equal(("h", 300d), (summer.RuntimeUnit, summer.RuntimeTotal!.Value!.Value));
Assert.Equal((TankRateSource.Empirical, "L/h"), (summer.Rate!.Source, summer.Rate.Unit));
Assert.Equal((700d / 300, BucketStatus.Available), (summer.Rate.Value.Value!.Value, summer.Rate.Value.Status));
await using (var db = fx.CreateContext())
{
await db.Tanks.Where(t => t.MeterId == tank).ExecuteUpdateAsync(s => s
.SetProperty(t => t.RateMode, TankRateMode.Fixed)
.SetProperty(t => t.FixedRate, 2.1));
}
var fixedRate = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 7, 31)));
Assert.Equal((TankRateSource.Fixed, 2.1), (fixedRate.Rate!.Source, fixedRate.Rate.Value.Value!.Value));
}
[Fact]
public async Task A_consumable_meter_without_a_tank_is_listed_for_setup()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("L");
var bare = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", name: $"Tank {Guid.NewGuid():N}");
var analysis = await Service().GetAsync(new ConsumableRequest(Preset(PeriodPreset.Last12Months)));
Assert.Empty(analysis.Tanks);
Assert.Equal([bare], analysis.Unconfigured.Select(u => u.MeterId));
Assert.Null(analysis.Quantities);
Assert.Null(await Service().GetAvailabilityAsync(Now));
}
private async Task<TankAnalysis> TankAsync(int tank, ResolvedPeriod period)
{
var analysis = await Service().GetAsync(new ConsumableRequest(period) { Bucket = BucketSize.Month });
return analysis.Tanks.Single(t => t.MeterId == tank);
}
private ConsumableService Service()
{
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
var reader = new AnalysisReader(fx, options);
return new ConsumableService(fx, reader, new CostReader(fx, reader, options));
}
private async Task<int> TankAsync(CostSandbox box, short type, double capacity, DateOnly? installedAt = null)
{
var meter = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", installedAt);
await using var db = fx.CreateContext();
db.Tanks.Add(new Tank { MeterId = meter, Capacity = capacity, Unit = "L" });
await db.SaveChangesAsync();
return meter;
}
private async Task EventsAsync(params MeterEvent[] events)
{
await using var db = fx.CreateContext();
db.MeterEvents.AddRange(events);
await db.SaveChangesAsync();
}
private static MeterEvent Level(int meterId, DateTimeOffset time, double litres) =>
new() { MeterId = meterId, EventType = MeterEventType.TankLevel, Time = time.ToUniversalTime(), Amount = litres, Unit = "L" };
private static MeterEvent Delivery(int meterId, DateTimeOffset time, double litres) =>
new() { MeterId = meterId, EventType = MeterEventType.Delivery, Time = time.ToUniversalTime(), Amount = litres, Unit = "L" };
}
@@ -0,0 +1,101 @@
using MeterVault.Core.Analysis;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.Integration.Tests.Specialized;
/// <summary>
/// The pure arithmetic behind the Solar view's derived figures (brief §4.3, A05): an input without data makes the bucket
/// unknown while an observed zero is a valid input; partial stays partial; a total never subtracts two totals over
/// different stretches of time; units are never mixed (D-20); a change needs the same buckets on both sides (D-07).
/// </summary>
public sealed class SolarFiguresTests
{
private static BucketValue Ok(double value) => BucketValue.Available(value, Provenance.Imported);
private static BucketValue Part(double value) => new(value, BucketStatus.Partial, Provenance.Imported, ValueIssue.PartialCoverage);
private static readonly BucketValue None = BucketValue.Missing();
[Fact]
public void An_observed_zero_is_an_input_and_a_missing_value_is_not()
{
var zero = SolarService.Combine(Ok(300), Ok(0), -1);
Assert.Equal((300d, BucketStatus.Available), (zero.Value, zero.Status));
Assert.True(zero.Provenance.HasFlag(Provenance.Derived));
var missing = SolarService.Combine(Ok(300), None, -1);
Assert.Equal(((double?)null, BucketStatus.Missing, ValueIssue.MissingSource), (missing.Value, missing.Status, missing.Issue));
var neither = SolarService.Combine(None, None, -1);
Assert.Equal((BucketStatus.Missing, ValueIssue.NoCoverage), (neither.Status, neither.Issue));
var partial = SolarService.Combine(Part(300), Ok(100), -1);
Assert.Equal((200d, BucketStatus.Partial), (partial.Value, partial.Status));
// Being prepared or too coarse decides the status, even beside a missing input.
var pending = SolarService.Combine(None, new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending), -1);
Assert.Equal(BucketStatus.Pending, pending.Status);
var coarse = SolarService.Combine(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), Ok(1), 1);
Assert.Equal((BucketStatus.Unresolved, ValueIssue.CoarseResolution), (coarse.Status, coarse.Issue));
}
[Fact]
public void A_total_is_taken_over_the_buckets_both_inputs_know_unless_both_are_complete()
{
// Load known February and March (700), import known January to March (800): the difference of the totals would
// be 100; over the months both know it is 300, partial.
List<BucketValue> load = [None, Ok(400), Ok(300)];
List<BucketValue> import = [Ok(400), Ok(300), Ok(100)];
var a = new SolarFigure(SolarBasis.Measured, "kWh", load, Part(700)) { InputUnits = ["kWh"] };
var b = new SolarFigure(SolarBasis.Measured, "kWh", import, Ok(800)) { InputUnits = ["kWh"] };
var self = SolarService.Derive(SolarBasis.LoadMinusImport, a, b, -1);
Assert.Equal([null, 100d, 200d], self.Values.Select(v => v.Value));
Assert.Equal((300d, BucketStatus.Partial), (self.Total.Value, self.Total.Status));
// Both complete: the totals decide (a monthly import resolves the period even where it does not resolve days).
var whole = SolarService.CombineTotal(Ok(700), Ok(400), -1, [new BucketValue(null, BucketStatus.Unresolved, Provenance.None)]);
Assert.Equal((300d, BucketStatus.Available), (whole.Value, whole.Status));
}
[Fact]
public void Figures_in_different_units_are_never_combined()
{
var generation = new SolarFigure(SolarBasis.Measured, "Wh", [Ok(5000)], Ok(5000)) { InputUnits = ["Wh"] };
var export = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(1)], Ok(1)) { InputUnits = ["kWh"] };
var self = SolarService.Derive(SolarBasis.GenerationMinusExport, generation, export, -1);
Assert.True(self.UnitsDiffer);
Assert.Equal(["Wh", "kWh"], self.InputUnits);
Assert.Equal(((double?)null, BucketStatus.Invalid), (self.Total.Value, self.Total.Status));
Assert.Equal(BucketStatus.Invalid, SolarService.Share(self, generation).Status);
Assert.Equal(BucketStatus.Invalid, SolarService.Share(export, generation).Status);
}
[Fact]
public void A_share_needs_a_positive_base_and_says_when_it_is_partial()
{
var part = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(30), None], Part(30)) { InputUnits = ["kWh"] };
var whole = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(60), Ok(40)], Ok(100)) { InputUnits = ["kWh"] };
var share = SolarService.Share(part, whole);
Assert.Equal((50d, BucketStatus.Partial), (share.Value, share.Status));
var zero = new SolarFigure(SolarBasis.Measured, "kWh", [Ok(0)], Ok(0)) { InputUnits = ["kWh"] };
Assert.Equal(BucketStatus.Invalid, SolarService.Share(zero, zero).Status);
}
[Fact]
public void A_change_compares_the_same_complete_buckets_on_both_sides()
{
// Complete totals: between them. Otherwise only the pairs complete on both sides (here the first).
Assert.Equal(20, SolarService.MatchedChange(Ok(120), [Ok(120)], Ok(100), [Ok(100)]).Absolute);
var matched = SolarService.MatchedChange(Part(150), [Ok(120), Part(30)], Ok(200), [Ok(100), Ok(100)]);
Assert.Equal((20d, 20d), (matched.Absolute, matched.Percent));
Assert.False(SolarService.MatchedChange(Part(30), [None, Part(30)], Ok(200), [Ok(100), Ok(100)]).IsAvailable);
Assert.False(SolarService.MatchedChange(Ok(1), [Ok(1)], null, null).IsAvailable);
}
}
@@ -0,0 +1,321 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using MeterVault.Integration.Tests.Costing;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Specialized;
/// <summary>
/// The Solar view's read model (brief §7.5, D-54) on the shared readers: generation from the type's generation measure
/// (a virtual sum is listed, never added twice), self-consumption / feed-in / site use / autarky as far as the roles
/// allow — unknown where an input has no data, never a zero — each role that nobody holds with its candidates, units from
/// the meters (D-20), and savings and the feed-in credit from the cost engine (D-34 D-38).
/// </summary>
/// <remarks>
/// The view reads every energy type with generation, so every test starts from — and leaves — an instance without meters,
/// tariffs or manual costs (like <see cref="DashboardServicesTests"/>); the sandbox tests pick their own type's section.
/// </remarks>
[Collection("Timescale")]
public sealed class SolarServiceTests(TimescaleFixture fx) : IAsyncLifetime
{
private const int SolarErzeugung = 12;
private const int NetzEinsparung = 13;
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task The_seeded_solar_view_reproduces_the_sheet()
{
// Generation month by month is the sheet's Solar Erzeugung; self-consumption (Haus Netz) its Netz Einsparung;
// feed-in, without a grid export meter, generation self-consumption — the sheet's 16,481 8,368 = 8,113 kWh.
await LoadReferenceDataAsync();
var rows = ReadRows(Electricity);
var solar = await Service().GetAsync(new SolarRequest(Custom(D(1997, 1, 1), D(2026, 12, 31))) { Bucket = BucketSize.Month });
var site = Assert.Single(solar.Sites);
var months = site.Quantities.Plan.Buckets;
Assert.Equal("kWh", site.Unit);
Assert.Equal("kWh", site.Generation!.Unit);
AssertReconciles(ByMonth(months, site.Generation.Values), OracleByMonth(rows, 0, SolarErzeugung, 1), 0.5, "generation", minMatches: 20);
AssertReconciles(ByMonth(months, site.SelfConsumption!.Values), OracleByMonth(rows, 0, NetzEinsparung, 1), 0.5, "self-consumption", minMatches: 20);
Assert.InRange(site.Generation.Total.Value!.Value, 16481 - 1, 16481 + 1);
Assert.InRange(site.SelfConsumption.Total.Value!.Value, 8368 - 1, 8368 + 1);
Assert.Equal(SolarBasis.LoadMinusImport, site.SelfConsumption.Basis);
Assert.Equal(SolarBasis.GenerationMinusSelfConsumption, site.FeedIn!.Basis);
Assert.InRange(site.FeedIn.Total.Value!.Value, 8113 - 2, 8113 + 2);
Assert.Equal(SolarBasis.Measured, site.SiteUse!.Basis);
Assert.InRange(site.SiteUse.Total.Value!.Value, 51909 - 1, 51909 + 1);
// Summe Solar (= Solar 1 + Solar 2) is listed as a calculated view and not added a second time.
await using var db = fx.CreateContext();
var byName = await db.Meters.ToDictionaryAsync(m => m.Name, m => m.Id);
Assert.Equal(
[("Zähler Solar 1", true), ("Zähler Solar 2", true), ("Summe Solar", false)],
site.Meters.Select(m => (m.Name, m.IsCounted)));
Assert.True(site.Meters.Single(m => m.Name == "Summe Solar").IsVirtual);
Assert.Equal(site.Generation.Total.Value!.Value, site.Meters.Where(m => m.IsCounted).Sum(m => m.Total.Value!.Value), 6);
// Roles: the house and the grid meter hold theirs; nobody exports, and the car meter could.
Assert.Equal([byName["Zähler Haus"]], site.RoleOf(MeterRole.TotalLoad).Holders.Select(h => h.MeterId));
Assert.Equal([byName["Zähler Netz"]], site.RoleOf(MeterRole.GridImport).Holders.Select(h => h.MeterId));
var export = site.RoleOf(MeterRole.GridExport);
Assert.False(export.IsSet);
Assert.Equal(["Zähler Auto"], export.Candidates.Select(c => c.Name));
// Savings: self-consumption at the grid meter's price, month by month — the priced history starts in September
// 2022, before the first self-consumption — and no feed-in credit without an export meter.
var savings = site.Savings!;
Assert.Equal([byName["Zähler Netz"]], savings.MeterIds);
Assert.Equal(CostStatus.Priced, savings.Total.Status);
Assert.Equal(savings.Buckets.Sum(b => b.Cost ?? 0), savings.Total.Cost!.Value, 6);
var january2025 = months.Select((b, i) => (b, i)).Single(p => p.b.FirstDay == D(2025, 1, 1)).i;
Assert.Equal(site.SelfConsumption.Values[january2025].Value!.Value * 0.36, savings.Buckets[january2025].Cost!.Value, 6);
Assert.Null(site.FeedInCredit);
Assert.InRange(site.Autarky!.Value!.Value, (8368.0 / 51909 * 100) - 0.1, (8368.0 / 51909 * 100) + 0.1);
}
[Fact]
public async Task Self_consumption_is_unknown_where_a_role_meter_has_no_data()
{
// A05: the house meter starts in February, so January has no self-consumption — not "0 grid" and not "all of
// the grid". Savings are priced from March, when the grid meter's price starts: February's are unknown, not free.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100, 200, 300);
var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await box.MonthlyReadingsAsync(grid, D(2026, 1, 1), 400, 300, 100);
var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
await box.MonthlyReadingsAsync(house, D(2026, 2, 1), 400, 300);
await box.TypePriceAsync(type, 0.30, D(2026, 3, 1));
var site = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 3, 31)));
var self = site.SelfConsumption!;
Assert.Equal(
[(null, BucketStatus.Missing, ValueIssue.MissingSource), (100d, BucketStatus.Available, ValueIssue.None), (200d, BucketStatus.Available, ValueIssue.None)],
self.Values.Select(v => (v.Value, v.Status, v.Issue)));
Assert.Equal((300d, BucketStatus.Partial), (self.Total.Value!.Value, self.Total.Status));
Assert.True(self.Values[1].Provenance.HasFlag(Provenance.Derived));
// Feed-in (generation self-consumption) has the same gap; generation itself is complete.
Assert.Equal([null, 100d, 100d], site.FeedIn!.Values.Select(v => v.Value));
Assert.Equal((600d, BucketStatus.Available), (site.Generation!.Total.Value!.Value, site.Generation.Total.Status));
// Shares over the months both figures know: autarky 300 / 700, self-consumption share 300 / (200 + 300).
Assert.Equal((300d / 700 * 100, BucketStatus.Partial), (site.Autarky!.Value!.Value, site.Autarky.Status));
Assert.Equal((60d, BucketStatus.Partial), (Math.Round(site.SelfConsumptionShare!.Value!.Value, 6), site.SelfConsumptionShare.Status));
// Savings: January unknown (no self-consumption), February a price gap, March 200 × 0.30.
var savings = site.Savings!;
Assert.Equal([null, null, 60d], savings.Buckets.Select(b => b.Cost is { } c ? Math.Round(c, 6) : (double?)null));
Assert.Equal(CostStatus.PriceGap, savings.Buckets[1].Status);
Assert.Equal((60d, CostStatus.Partial), (Math.Round(savings.Total.Cost!.Value, 6), savings.Total.Status));
Assert.Contains(site.CostAttention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price!.Reason == CostStatus.PriceGap);
}
[Fact]
public async Task A_missing_role_is_named_with_the_meters_that_could_take_it()
{
// Only a generation meter and two ordinary meters: every role is missing, the figures that need them are absent
// (not zero), and each role lists the meters whose mode may hold it — never the generation, runtime or virtual one.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100, 120);
await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", name: $"B {Guid.NewGuid():N}");
await box.MeterAsync(type, MeterMode.DirectDelta, "kWh", name: $"A {Guid.NewGuid():N}");
await box.MeterAsync(type, MeterMode.RuntimeCounter, "h");
await box.VirtualAsync(type, $"m{pv}", QuantityKind.Generation, "kWh", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None);
var site = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)));
Assert.Equal(220, site.Generation!.Total.Value!.Value, 6);
Assert.Null(site.SelfConsumption);
Assert.Null(site.FeedIn);
Assert.Null(site.SiteUse);
Assert.Null(site.Autarky);
Assert.Null(site.Savings);
Assert.Null(site.FeedInCredit);
Assert.Equal(MeterRoleRules.All, site.Roles.Select(r => r.Role));
Assert.All(site.Roles, role =>
{
Assert.False(role.IsSet);
Assert.Equal(2, role.Candidates.Count);
Assert.StartsWith("A ", role.Candidates[0].Name, StringComparison.Ordinal);
Assert.StartsWith("B ", role.Candidates[1].Name, StringComparison.Ordinal);
});
}
[Fact]
public async Task A_grid_export_meter_gives_measured_feed_in_and_its_credit()
{
// Generation 500 a month, export 200, import 100, no house meter: self-consumption is generation export (300),
// site use self-consumption + import (400), autarky 75 %. The credit is the bill's feed-in line: 200 × 0.08.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 500, 500);
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), 200, 200);
var import = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await box.MonthlyReadingsAsync(import, D(2026, 1, 1), 100, 100);
await box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
await box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.FeedIn, 0.08, "EUR/kWh", D(2025, 1, 1));
var site = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)));
Assert.Equal((SolarBasis.GenerationMinusExport, 600d), (site.SelfConsumption!.Basis, site.SelfConsumption.Total.Value!.Value));
Assert.Equal((SolarBasis.Measured, 400d), (site.FeedIn!.Basis, site.FeedIn.Total.Value!.Value));
Assert.Equal([export], site.FeedIn.MeterIds);
Assert.Equal((SolarBasis.SelfConsumptionPlusImport, 800d), (site.SiteUse!.Basis, site.SiteUse.Total.Value!.Value));
Assert.Equal(75, site.Autarky!.Value!.Value, 6);
Assert.Equal(60, site.SelfConsumptionShare!.Value!.Value, 6);
Assert.Equal(600 * 0.30, site.Savings!.Total.Cost!.Value, 6);
Assert.Equal([import], site.Savings.MeterIds);
Assert.Equal((32d, CostStatus.Priced), (Math.Round(site.FeedInCredit!.Total.FeedInCredit!.Value, 6), site.FeedInCredit.Total.Status));
Assert.Equal([16d, 16d], site.FeedInCredit.Buckets.Select(b => Math.Round(b.FeedInCredit!.Value, 6)));
Assert.True(site.RoleOf(MeterRole.GridExport).IsSet);
Assert.False(site.RoleOf(MeterRole.TotalLoad).IsSet);
}
[Fact]
public async Task Units_come_from_the_meters_and_are_never_combined()
{
// D-20: a generation counter in Wh reads in Wh — nothing assumes kWh. A second one in kWh is its own measure, not
// added; and an export meter in kWh cannot be subtracted from generation in Wh.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync("Wh");
var small = await box.MeterAsync(type, MeterMode.GenerationCounter, "Wh", D(2026, 1, 1));
await box.MonthlyReadingsAsync(small, D(2026, 1, 1), 5000, 7000);
var single = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)));
Assert.Equal(("Wh", "Wh", 12000d), (single.Unit, single.Generation!.Unit, single.Generation.Total.Value!.Value));
Assert.Empty(single.OtherGeneration);
var big = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", D(2026, 1, 1));
await box.MonthlyReadingsAsync(big, D(2026, 1, 1), 3, 4);
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), 1, 1);
var mixed = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)));
// The export meter's unit picks the generation measure in kWh; the one in Wh is reported apart.
Assert.Equal(("kWh", 7d), (mixed.Generation!.Unit, mixed.Generation.Total.Value!.Value));
var other = Assert.Single(mixed.OtherGeneration);
Assert.Equal(("Wh", 12000d), (other.Unit, other.Total.Value!.Value));
Assert.Equal(5, mixed.SelfConsumption!.Total.Value!.Value, 6);
// An export in another unit than generation: self-consumption cannot be calculated, and says which units clash.
await using (var db = fx.CreateContext())
{
await db.Readings.Where(r => r.MeterId == big).ExecuteDeleteAsync();
await db.Consumption.Where(c => c.MeterId == big).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == big).ExecuteDeleteAsync();
}
var clash = await SiteAsync(type, Custom(D(2026, 1, 1), D(2026, 2, 28)));
Assert.Equal("Wh", clash.Generation!.Unit);
Assert.True(clash.SelfConsumption!.UnitsDiffer);
Assert.Equal(["Wh", "kWh"], clash.SelfConsumption.InputUnits);
Assert.All(clash.SelfConsumption.Values, v => Assert.Equal((null, BucketStatus.Invalid), (v.Value, v.Status)));
Assert.Equal(BucketStatus.Invalid, clash.SelfConsumptionShare!.Status);
Assert.Null(clash.Savings);
}
[Fact]
public async Task Without_a_generation_meter_there_is_no_section()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100);
var solar = await Service().GetAsync(new SolarRequest(Custom(D(2026, 1, 1), D(2026, 1, 31))));
Assert.Empty(solar.Sites);
Assert.Null(solar.Plan);
Assert.Null(await Service().GetAvailabilityAsync(Now));
}
[Fact]
public async Task A_period_without_data_is_missing_not_zero_and_the_availability_says_where_data_is()
{
// The seeded case of the brief: data until May 2026, today 19 September — month to date has nothing.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 3, 1), 100, 120, 130);
var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 3, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
await box.MonthlyReadingsAsync(grid, D(2026, 3, 1), 50, 50, 50);
var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 3, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
await box.MonthlyReadingsAsync(house, D(2026, 3, 1), 90, 90, 90);
var site = await SiteAsync(type, Preset(PeriodPreset.MonthToDate));
Assert.Equal(BucketStatus.Missing, site.Generation!.Total.Status);
Assert.Null(site.Generation.Total.Value);
Assert.Equal(BucketStatus.Missing, site.SelfConsumption!.Total.Status);
Assert.Null(site.SelfConsumption.Total.Value);
Assert.Null(site.Autarky!.Value);
Assert.Null(site.Savings?.Total.Cost);
Assert.Equal(D(2026, 5, 31), site.Availability!.LastDay);
Assert.Equal(D(2026, 5, 31), (await Service().GetAvailabilityAsync(Now))!.LastDay);
}
private async Task<SolarSite> SiteAsync(short type, ResolvedPeriod period)
{
var solar = await Service().GetAsync(new SolarRequest(period) { Bucket = BucketSize.Month });
return solar.Sites.Single(s => s.EnergyTypeId == type);
}
private SolarService Service()
{
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
var reader = new AnalysisReader(fx, options);
return new SolarService(fx, reader, new CostReader(fx, reader, options));
}
private static Dictionary<DateOnly, double> ByMonth(IReadOnlyList<AnalysisBucket> buckets, IReadOnlyList<BucketValue> values) =>
buckets.Select((b, i) => (b.FirstDay, values[i].Value))
.Where(p => p.Value is not null)
.ToDictionary(p => p.FirstDay, p => p.Value!.Value);
private async Task LoadReferenceDataAsync()
{
await using var db = fx.CreateContext();
var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter());
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
internal static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
}
@@ -0,0 +1,104 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.Integration.Tests.Specialized;
/// <summary>
/// The pure rules behind a tank's contents and forecast (D-54, D-09): contents at an instant are the last dipstick up to it
/// plus the deliveries after it; the forecast is a straight line through the dipsticks of the year before the last one,
/// hidden when that one is older than 60 days or the line too short.
/// </summary>
public sealed class TankLevelsTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly DateTimeOffset Now = new(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2));
[Fact]
public void Contents_are_the_last_dipstick_plus_the_deliveries_after_it()
{
List<MeterEvent> events =
[
Level(At(2026, 8, 1), 2000),
Delivery(At(2026, 8, 1), 500), // at the dipstick's instant: already in it, as the normalizer books it
Delivery(At(2026, 8, 10), 1000),
Delivery(At(2026, 9, 1), 300),
];
var now = TankLevels.ContentsAt(events, Now, inclusive: true, calibration: null)!;
Assert.Equal((3300d, 1300d, 2), (now.Volume, now.DeliveredSince, now.DeliveriesSince));
// An exclusive end leaves out what happens at that instant.
var end = TankLevels.ContentsAt(events, At(2026, 9, 1), inclusive: false, calibration: null)!;
Assert.Equal((3000d, 1), (end.Volume, end.DeliveriesSince));
Assert.Null(TankLevels.ContentsAt(events, At(2026, 7, 31), inclusive: true, calibration: null));
}
[Fact]
public void A_centimetre_dipstick_is_calibrated_and_keeps_what_was_read()
{
var level = new MeterEvent { MeterId = 1, EventType = MeterEventType.TankLevel, Time = At(2026, 9, 1), Amount = 85, Unit = "cm" };
var calibrated = TankLevels.Dipstick(level, new CalibrationCurve(46.67));
Assert.Equal((85 * 46.67, 85d, "cm", true), (calibrated.Volume, calibrated.Reading, calibrated.ReadingUnit, calibrated.IsCalibrated));
// Without a calibration the reading is taken as the volume it is — and says it was not calibrated.
var raw = TankLevels.Dipstick(level, calibration: null);
Assert.Equal((85d, false), (raw.Volume, raw.IsCalibrated));
}
[Fact]
public void The_forecast_says_why_it_is_missing()
{
Assert.Equal(TankForecastState.NoDipstick, TankLevels.Forecast([Delivery(At(2026, 9, 1), 1000)], Now, Berlin, null).State);
// One recent dipstick is no line.
var single = TankLevels.Forecast([Level(At(2026, 9, 1), 2000)], Now, Berlin, null);
Assert.Equal((TankForecastState.NotEnoughHistory, 18), (single.State, single.DipstickAgeDays));
// Two dipsticks 20 days apart are too short a line (at least 30 days).
var short_ = TankLevels.Forecast([Level(At(2026, 8, 20), 2200), Level(At(2026, 9, 9), 2000)], Now, Berlin, null);
Assert.Equal(TankForecastState.NotEnoughHistory, short_.State);
// Nothing drawn between the dipsticks (the delivery filled what was used): no end to project.
var none = TankLevels.Forecast([Level(At(2026, 6, 1), 2000), Delivery(At(2026, 7, 1), 500), Level(At(2026, 9, 1), 2500)], Now, Berlin, null);
Assert.Equal((TankForecastState.NoUse, 92), (none.State, none.BasisDays));
// A dipstick of 61 days is too old, whatever came before.
var old = TankLevels.Forecast([Level(At(2026, 5, 1), 3000), Level(At(2026, 7, 20), 2000)], Now, Berlin, null);
Assert.Equal((TankForecastState.DipstickTooOld, 61, (DateOnly?)null), (old.State, old.DipstickAgeDays, old.EmptyOn));
}
[Fact]
public void The_forecast_draws_through_a_year_of_dipsticks_and_adds_the_deliveries_back()
{
// 1 October 2025: 5,000 L; 1 January 2026 a 2,000 L delivery; 1 September 2026: 1,500 L. Dipsticks older than a
// year before the last one are ignored. Draw 5,000 + 2,000 1,500 = 5,500 L over 335 days; 1,500 L plus the
// 200 L delivered on 10 September last 1,700 ÷ (5,500 ÷ 335) = 103.5 days from 1 September.
List<MeterEvent> events =
[
Level(At(2024, 1, 1), 9000),
Level(At(2025, 10, 1), 5000),
Delivery(At(2026, 1, 1), 2000),
Level(At(2026, 9, 1), 1500),
Delivery(At(2026, 9, 10), 200),
];
var forecast = TankLevels.Forecast(events, Now, Berlin, null);
Assert.Equal(TankForecastState.Projected, forecast.State);
Assert.Equal(335, forecast.BasisDays);
Assert.Equal(5500d / 335, forecast.PerDay!.Value, 6);
Assert.Equal(new DateOnly(2026, 12, 13), forecast.EmptyOn);
}
private static DateTimeOffset At(int year, int month, int day) =>
GapAttribution.LocalMidnight(new DateOnly(year, month, day), Berlin);
private static MeterEvent Level(DateTimeOffset time, double litres) =>
new() { MeterId = 1, EventType = MeterEventType.TankLevel, Time = time, Amount = litres, Unit = "L" };
private static MeterEvent Delivery(DateTimeOffset time, double litres) =>
new() { MeterId = 1, EventType = MeterEventType.Delivery, Time = time, Amount = litres, Unit = "L" };
}