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,38 @@
using System.Globalization;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Frozen instants and zones for the period tests — nothing here reads the wall clock.</summary>
internal static class AnalysisClock
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
/// <summary>An unambiguous local wall-clock time in <paramref name="zone"/>, as an instant.</summary>
public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0)
{
var wall = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
Assert.False(zone.IsInvalidTime(wall), $"{wall:s} does not exist in {zone.Id}");
Assert.False(zone.IsAmbiguousTime(wall), $"{wall:s} is ambiguous in {zone.Id}; give the offset");
return new DateTimeOffset(wall, zone.GetUtcOffset(wall));
}
/// <summary>A local wall-clock time with an explicit offset — for the hour an autumn fold repeats.</summary>
public static DateTimeOffset At(int year, int month, int day, int hour, int minute, int offsetHours) =>
new(year, month, day, hour, minute, 0, TimeSpan.FromHours(offsetHours));
public static DateTimeOffset Utc(int year, int month, int day, int hour = 0, int minute = 0) =>
new(year, month, day, hour, minute, 0, TimeSpan.Zero);
public static DateOnly Day(int year, int month, int day) => new(year, month, day);
public static DateOnly Iso(string date) => DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture);
public static DateTimeOffset IsoInstant(string instant) =>
DateTimeOffset.ParseExact(instant, "yyyy-MM-dd'T'HH:mm'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
/// <summary>The wall clock an instant shows in <paramref name="zone"/>, for readable assertions.</summary>
public static string Wall(DateTimeOffset instant, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(instant, zone).ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,26 @@
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Fixed instants in the zones the interval and coverage tests reason about.</summary>
internal static class AnalysisTestTime
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
/// <summary>A wall-clock time in <paramref name="zone"/> as an instant.</summary>
public static DateTimeOffset Local(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0, int second = 0)
{
var wall = new DateTime(year, month, day, hour, minute, second);
return new DateTimeOffset(wall, zone.GetUtcOffset(wall));
}
public static DateTimeOffset InBerlin(int year, int month, int day, int hour = 0, int minute = 0, int second = 0) =>
Local(Berlin, year, month, day, hour, minute, second);
public static DateTimeOffset Utc(int year, int month, int day, int hour = 0, int minute = 0) =>
new(year, month, day, hour, minute, 0, TimeSpan.Zero);
/// <summary>The local calendar date an instant falls on.</summary>
public static DateOnly LocalDate(DateTimeOffset instant, TimeZoneInfo zone) =>
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime);
}
@@ -0,0 +1,226 @@
using System.Globalization;
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// URL tokens are stable invariant identifiers (D-02, D-46): what a German browser writes, an English one
/// reads. Parsing never throws, so a hand-edited or stale link falls back to the page default instead of
/// breaking the page.
/// </summary>
public sealed class AnalysisTokensTests
{
[Theory]
[InlineData(PeriodPreset.MonthToDate, "mtd")]
[InlineData(PeriodPreset.LastMonth, "last-month")]
[InlineData(PeriodPreset.YearToDate, "ytd")]
[InlineData(PeriodPreset.PreviousYear, "prev-year")]
[InlineData(PeriodPreset.Last12Months, "12m")]
[InlineData(PeriodPreset.Last24Months, "24m")]
[InlineData(PeriodPreset.AllHistory, "all")]
[InlineData(PeriodPreset.Custom, "custom")]
public void Every_period_preset_has_its_documented_token_and_parses_back(PeriodPreset preset, string token)
{
Assert.Equal(token, AnalysisTokens.Format(preset));
Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed));
Assert.Equal(preset, parsed);
}
[Theory]
[InlineData(BucketSize.Auto, "auto")]
[InlineData(BucketSize.Day, "day")]
[InlineData(BucketSize.Week, "week")]
[InlineData(BucketSize.Month, "month")]
[InlineData(BucketSize.Year, "year")]
public void Every_bucket_size_has_its_documented_token_and_parses_back(BucketSize size, string token)
{
Assert.Equal(token, AnalysisTokens.Format(size));
Assert.True(AnalysisTokens.TryParseBucket(token, out var parsed));
Assert.Equal(size, parsed);
}
[Fact]
public void Every_enum_value_has_a_token_so_no_state_is_unlinkable()
{
Assert.All(Enum.GetValues<PeriodPreset>(), p => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(p))));
Assert.All(Enum.GetValues<BucketSize>(), b => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(b))));
}
[Theory]
[InlineData("none", ComparisonKind.None, null)]
[InlineData("prev-period", ComparisonKind.PreviousPeriod, null)]
[InlineData("prev-year", ComparisonKind.PreviousYear, null)]
[InlineData("year:2025", ComparisonKind.Year, 2025)]
[InlineData("year:1997", ComparisonKind.Year, 1997)]
public void Comparison_tokens_round_trip(string token, ComparisonKind kind, int? year)
{
Assert.True(AnalysisTokens.TryParseComparison(token, out var request));
Assert.Equal(new ComparisonRequest(kind, year), request);
Assert.Equal(token, AnalysisTokens.Format(request));
}
[Fact]
public void Parsing_none_returns_the_shared_none_request()
{
Assert.True(AnalysisTokens.TryParseComparison("none", out var request));
Assert.Same(ComparisonRequest.None, request);
}
[Theory]
[InlineData("MTD", PeriodPreset.MonthToDate)]
[InlineData(" 12m ", PeriodPreset.Last12Months)]
[InlineData("Last-Month", PeriodPreset.LastMonth)]
public void Period_tokens_are_read_regardless_of_case_and_surrounding_blanks(string token, PeriodPreset expected)
{
Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed));
Assert.Equal(expected, parsed);
}
[Theory]
[InlineData("previous-year", ComparisonKind.PreviousYear)]
[InlineData("previous-period", ComparisonKind.PreviousPeriod)]
[InlineData("PREV-YEAR", ComparisonKind.PreviousYear)]
[InlineData(" Year:2024 ", ComparisonKind.Year)]
public void Spelled_out_comparison_aliases_are_accepted_but_never_written(string token, ComparisonKind expected)
{
Assert.True(AnalysisTokens.TryParseComparison(token, out var request));
Assert.Equal(expected, request.Kind);
Assert.DoesNotContain("previous", AnalysisTokens.Format(request), StringComparison.Ordinal);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("month-to-date")]
[InlineData("13m")]
[InlineData("today")]
[InlineData("mtd;drop table")]
[InlineData("mtd2")]
public void Unknown_period_tokens_are_rejected_without_throwing(string? token)
{
Assert.False(AnalysisTokens.TryParsePeriod(token, out _));
Assert.False(AnalysisTokens.TryParseBucket(token, out _));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("year")]
[InlineData("year:")]
[InlineData("year:25")]
[InlineData("year:20250")]
[InlineData("year:-202")]
[InlineData("year:+202")]
[InlineData("year: 2025")]
[InlineData("year:2025.0")]
[InlineData("year:2025")] // full-width digits
[InlineData("year:1899")]
[InlineData("year:2300")]
[InlineData("year:abcd")]
[InlineData("last-year")]
public void Malformed_or_out_of_range_comparison_tokens_are_rejected_without_throwing(string? token)
{
Assert.False(AnalysisTokens.TryParseComparison(token, out var request));
Assert.Null(request);
}
[Fact]
public void A_long_garbage_token_is_rejected_without_throwing()
{
var garbage = new string('x', 100_000);
Assert.False(AnalysisTokens.TryParsePeriod(garbage, out _));
Assert.False(AnalysisTokens.TryParseComparison("year:" + garbage, out _));
Assert.False(AnalysisTokens.TryParseDate(garbage, out _));
}
[Theory]
[InlineData("2026-09-19", 2026, 9, 19)]
[InlineData("2028-02-29", 2028, 2, 29)]
[InlineData("1900-01-01", 1900, 1, 1)]
[InlineData("2299-12-31", 2299, 12, 31)]
public void Iso_dates_parse_exactly(string token, int year, int month, int day)
{
Assert.True(AnalysisTokens.TryParseDate(token, out var date));
Assert.Equal(Day(year, month, day), date);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("2026-9-19")]
[InlineData("19.09.2026")]
[InlineData("09/19/2026")]
[InlineData("2026-02-29")]
[InlineData("2026-13-01")]
[InlineData("2026-09-19T00:00")]
[InlineData("1899-12-31")]
[InlineData("2300-01-01")]
[InlineData("9999-12-31")]
public void Dates_in_any_other_layout_or_outside_the_supported_range_are_rejected(string? token)
{
Assert.False(AnalysisTokens.TryParseDate(token, out var date));
Assert.Equal(default(DateOnly), date);
}
[Fact]
public void Dates_are_written_invariantly_whatever_the_reader_culture()
{
var saved = CultureInfo.CurrentCulture;
try
{
foreach (var culture in new[] { "de-DE", "ar-SA", "th-TH" })
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
Assert.Equal("2026-09-19", AnalysisTokens.FormatDate(Day(2026, 9, 19)));
Assert.Equal("year:2025", AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year, 2025)));
Assert.True(AnalysisTokens.TryParseDate("2026-09-19", out var parsed));
Assert.Equal(Day(2026, 9, 19), parsed);
}
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
[Fact]
public void A_custom_range_needs_both_dates_in_order()
{
Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-09-30", out var first, out var last));
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 30)), (first, last));
Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-19", "2026-09-19", out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-30", "2026-09-01", out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", null, out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange(null, "2026-09-30", out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", "30.09.2026", out _, out _));
}
[Fact]
public void A_parsed_custom_range_resolves_without_error()
{
Assert.True(AnalysisTokens.TryParsePeriod("custom", out var preset));
Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-12-31", out var first, out var last));
var period = PeriodResolver.Resolve(preset, first, last, At(Berlin, 2026, 9, 19, 14, 37), Berlin);
Assert.True(period.ExtendsPastNow);
Assert.Equal("2026-12-31", AnalysisTokens.FormatDate(period.LastDay));
}
[Fact]
public void Formatting_a_year_comparison_without_a_year_is_a_programming_error()
{
Assert.Throws<ArgumentException>(() => AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year)));
}
[Fact]
public void Formatting_an_undefined_enum_value_is_a_programming_error()
{
Assert.Throws<ArgumentOutOfRangeException>(() => AnalysisTokens.Format((PeriodPreset)99));
Assert.Throws<ArgumentOutOfRangeException>(() => AnalysisTokens.Format((BucketSize)99));
}
}
@@ -0,0 +1,502 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Buckets are local calendar units clipped to the period (D-05). What these pin down: "last 12 months" is
/// exactly 12 buckets ending at now; weeks start on Monday; DST days are 23 or 25 hours; Auto picks one
/// sensible size that never undercuts the data's resolution; an explicit size over 400 points is refused
/// with a coarser suggestion rather than truncated.
/// </summary>
public sealed class BucketPlannerTests
{
private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37);
private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, zone ?? Berlin);
private static void AssertTiles(ResolvedPeriod period, IReadOnlyList<AnalysisBucket> buckets)
{
Assert.NotEmpty(buckets);
Assert.Equal(period.From, buckets[0].From);
Assert.Equal(period.To, buckets[^1].To);
Assert.Equal(period.FirstDay, buckets[0].FirstDay);
for (var i = 1; i < buckets.Count; i++)
{
Assert.Equal(buckets[i - 1].To, buckets[i].From);
Assert.Equal(buckets[i - 1].EndDay, buckets[i].FirstDay);
}
}
[Fact]
public void The_last_12_months_give_exactly_12_month_buckets_the_last_one_ending_now()
{
var period = Preset(PeriodPreset.Last12Months);
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.False(plan.Refused);
Assert.Equal(12, plan.Buckets.Count);
Assert.Equal(12, plan.PointCount);
AssertTiles(period, plan.Buckets);
Assert.Equal(
["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"],
plan.Buckets.Select(b => $"{b.FirstDay.Year:D4}-{b.FirstDay.Month:D2}"));
var october = plan.Buckets[0];
Assert.Equal((Day(2025, 10, 1), Day(2025, 11, 1)), (october.FirstDay, october.EndDay));
Assert.Equal((Utc(2025, 9, 30, 22), Utc(2025, 10, 31, 23)), (october.From, october.To));
var september = plan.Buckets[^1];
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (september.FirstDay, september.EndDay));
Assert.Equal(September19, september.To);
Assert.All(plan.Buckets, b => Assert.Equal(BucketSize.Month, b.Size));
}
[Fact]
public void Across_New_Year_the_last_12_months_still_give_12_buckets_ending_with_the_new_January()
{
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2027, 1, 1, 0, 30));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(12, plan.Buckets.Count);
Assert.Equal(Day(2026, 2, 1), plan.Buckets[0].FirstDay);
Assert.Equal(Day(2027, 1, 1), plan.Buckets[^1].FirstDay);
Assert.Equal(TimeSpan.FromMinutes(30), plan.Buckets[^1].To - plan.Buckets[^1].From);
}
[Fact]
public void At_the_exact_midnight_that_starts_a_month_the_last_12_months_keep_12_buckets_the_new_one_empty()
{
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(12, plan.Buckets.Count);
Assert.Equal(Day(2026, 10, 1), plan.Buckets[^1].FirstDay);
Assert.Equal(plan.Buckets[^1].From, plan.Buckets[^1].To);
AssertTiles(period, plan.Buckets);
}
[Theory]
[InlineData(BucketSize.Day)]
[InlineData(BucketSize.Week)]
[InlineData(BucketSize.Month)]
[InlineData(BucketSize.Auto)]
public void At_the_exact_midnight_that_starts_a_month_month_to_date_has_one_empty_bucket_like_the_last_12_months(BucketSize size)
{
var now = At(Berlin, 2026, 10, 1);
var monthToDate = Preset(PeriodPreset.MonthToDate, now);
var last12 = Preset(PeriodPreset.Last12Months, now);
var plan = BucketPlanner.Plan(monthToDate, size);
// Both presets agree that October exists and is empty: no "not yet occurred" for one and an empty
// bucket for the other.
var today = Assert.Single(plan.Buckets);
Assert.Equal((Day(2026, 10, 1), now, now), (today.FirstDay, today.From, today.To));
Assert.Equal(1, plan.PointCount);
Assert.Equal(last12.To, BucketPlanner.Plan(last12, BucketSize.Month).Buckets[^1].To);
AssertTiles(monthToDate, plan.Buckets);
}
[Fact]
public void Week_buckets_start_on_Monday_and_the_first_one_starts_with_the_period()
{
// 1 September 2026 is a Tuesday.
var period = Preset(PeriodPreset.MonthToDate);
var plan = BucketPlanner.Plan(period, BucketSize.Week);
Assert.Equal(
[(Day(2026, 9, 1), Day(2026, 9, 7)), (Day(2026, 9, 7), Day(2026, 9, 14)), (Day(2026, 9, 14), Day(2026, 9, 20))],
plan.Buckets.Select(b => (b.FirstDay, b.EndDay)));
Assert.Equal(DayOfWeek.Tuesday, plan.Buckets[0].FirstDay.DayOfWeek);
Assert.All(plan.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, b.FirstDay.DayOfWeek));
Assert.Equal(September19, plan.Buckets[^1].To);
AssertTiles(period, plan.Buckets);
}
[Fact]
public void A_range_from_Monday_to_Sunday_is_whole_weeks()
{
var period = Custom(Day(2026, 9, 7), Day(2026, 9, 20), At(Berlin, 2026, 10, 1, 12, 0));
var plan = BucketPlanner.Plan(period, BucketSize.Week);
Assert.Equal(2, plan.Buckets.Count);
Assert.All(plan.Buckets, b => Assert.Equal(7, b.EndDay.DayNumber - b.FirstDay.DayNumber));
Assert.Equal(Utc(2026, 9, 20, 22), plan.Buckets[^1].To);
}
[Fact]
public void Day_buckets_follow_local_midnight_so_the_spring_DST_day_has_23_hours()
{
var march = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 2, 9, 0));
var plan = BucketPlanner.Plan(march, BucketSize.Day);
Assert.Equal(31, plan.Buckets.Count);
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 3, 29));
Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 22)), (dstDay.From, dstDay.To));
Assert.Equal(TimeSpan.FromHours(23), dstDay.To - dstDay.From);
Assert.All(plan.Buckets.Where(b => b != dstDay), b => Assert.Equal(TimeSpan.FromHours(24), b.To - b.From));
AssertTiles(march, plan.Buckets);
}
[Fact]
public void Day_buckets_follow_local_midnight_so_the_autumn_DST_day_has_25_hours()
{
var october = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0));
var plan = BucketPlanner.Plan(october, BucketSize.Day);
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 10, 25));
Assert.Equal((Utc(2026, 10, 24, 22), Utc(2026, 10, 25, 23)), (dstDay.From, dstDay.To));
Assert.Equal(TimeSpan.FromHours(25), dstDay.To - dstDay.From);
}
[Fact]
public void Behind_UTC_month_buckets_start_at_New_York_midnight()
{
var period = Custom(Day(2026, 10, 1), Day(2026, 11, 30), zone: NewYork, now: At(NewYork, 2026, 12, 15, 12, 0));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(
[(Utc(2026, 10, 1, 4), Utc(2026, 11, 1, 4)), (Utc(2026, 11, 1, 4), Utc(2026, 12, 1, 5))],
plan.Buckets.Select(b => (b.From, b.To)));
}
[Fact]
public void A_custom_range_past_now_gets_buckets_only_up_to_now()
{
var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
var only = Assert.Single(plan.Buckets);
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (only.FirstDay, only.EndDay));
Assert.Equal(September19, only.To);
}
[Fact]
public void A_period_that_has_not_started_or_has_no_history_has_no_buckets()
{
var future = Custom(Day(2027, 1, 1), Day(2027, 3, 31));
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin);
foreach (var period in new[] { future, none })
{
foreach (var size in new[] { BucketSize.Auto, BucketSize.Day, BucketSize.Year })
{
var plan = BucketPlanner.Plan(period, size);
Assert.Empty(plan.Buckets);
Assert.False(plan.Refused);
Assert.Equal(0, plan.PointCount);
}
}
}
[Theory]
[InlineData(PeriodPreset.MonthToDate, BucketSize.Day, 19)]
[InlineData(PeriodPreset.LastMonth, BucketSize.Day, 31)]
[InlineData(PeriodPreset.YearToDate, BucketSize.Month, 9)]
[InlineData(PeriodPreset.Last12Months, BucketSize.Month, 12)]
[InlineData(PeriodPreset.Last24Months, BucketSize.Month, 24)]
[InlineData(PeriodPreset.PreviousYear, BucketSize.Month, 12)]
public void Auto_picks_days_for_short_ranges_and_months_for_a_year_or_more(PeriodPreset preset, BucketSize expected, int points)
{
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Auto);
Assert.Equal(BucketSize.Auto, plan.Requested);
Assert.Equal(expected, plan.Size);
Assert.Equal(points, plan.Buckets.Count);
Assert.False(plan.Refused);
}
[Theory]
[InlineData(62, BucketSize.Day)]
[InlineData(63, BucketSize.Week)]
[InlineData(182, BucketSize.Week)]
[InlineData(183, BucketSize.Month)]
public void Auto_switches_from_days_to_weeks_after_62_days_and_to_months_after_26_weeks(int days, BucketSize expected)
{
var first = Day(2025, 1, 1);
var period = Custom(first, first.AddDays(days - 1));
Assert.Equal(expected, BucketPlanner.Plan(period, BucketSize.Auto).Size);
}
[Theory]
[InlineData(1, 1)]
[InlineData(2, 15)]
[InlineData(3, 2)]
[InlineData(6, 15)]
[InlineData(7, 2)]
[InlineData(9, 19)]
[InlineData(12, 31)]
public void Auto_charts_year_to_date_by_month_on_every_day_of_the_year(int month, int day)
{
// Chosen from the named year, not the elapsed part (A-06): the same URL used to render by day until
// 2 March and by week until 1 July.
var period = Preset(PeriodPreset.YearToDate, At(Berlin, 2026, month, day, 12, 0));
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
Assert.Equal(BucketSize.Month, plan.Size);
Assert.Equal(month, plan.Buckets.Count);
}
[Fact]
public void Auto_charts_month_to_date_by_day_even_on_the_1st()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 9, 1, 8, 0)), BucketSize.Auto);
Assert.Equal(BucketSize.Day, plan.Size);
Assert.Single(plan.Buckets);
}
[Fact]
public void Auto_sizes_a_custom_range_reaching_past_now_by_the_range_asked_for()
{
var first = Day(2026, 9, 1);
var last = Day(2026, 12, 31);
var inSeptember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 9, 19, 12, 0)), BucketSize.Auto);
var inNovember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 11, 20, 12, 0)), BucketSize.Auto);
Assert.Equal(BucketSize.Week, inSeptember.Size);
Assert.Equal(BucketSize.Week, inNovember.Size);
}
[Fact]
public void Auto_checks_the_point_limit_on_the_buckets_that_exist_up_to_now()
{
// The named year has 12 months, over a limit of 9; only the 9 that exist by 19 September are counted.
var plan = BucketPlanner.Plan(Preset(PeriodPreset.YearToDate), BucketSize.Auto, maxPoints: 9);
Assert.Equal(BucketSize.Month, plan.Size);
Assert.Equal(9, plan.PointCount);
Assert.False(plan.Refused);
}
[Fact]
public void Auto_weighs_months_against_the_point_limit_it_was_given()
{
// 501 months since 1985: over the default 400, within a limit of 1,000.
var since1985 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1985, 1, 1));
var generous = BucketPlanner.Plan(since1985, BucketSize.Auto, maxPoints: 1_000);
var standard = BucketPlanner.Plan(since1985, BucketSize.Auto);
Assert.Equal(BucketSize.Month, generous.Size);
Assert.Equal(501, generous.Buckets.Count);
Assert.Equal(BucketSize.Year, standard.Size);
}
[Fact]
public void Auto_charts_all_history_from_a_stray_ancient_reading_by_year_instead_of_refusing()
{
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(206, 5, 1));
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
Assert.False(plan.Refused);
Assert.Equal(BucketSize.Year, plan.Size);
Assert.Equal(2026 - 1900 + 1, plan.Buckets.Count);
}
[Fact]
public void Auto_uses_months_up_to_400_of_them_and_years_beyond()
{
// The reference data's oil history starts in 1997: 357 months to September 2026.
var since1997 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1));
var since1900 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1900, 1, 1));
var monthly = BucketPlanner.Plan(since1997, BucketSize.Auto);
var yearly = BucketPlanner.Plan(since1900, BucketSize.Auto);
Assert.Equal(BucketSize.Month, monthly.Size);
Assert.Equal(357, monthly.Buckets.Count);
Assert.Equal(BucketSize.Year, yearly.Size);
Assert.Equal(127, yearly.Buckets.Count);
}
[Fact]
public void Auto_never_goes_finer_than_the_coarsest_resolution_a_series_needs()
{
var monthToDate = Preset(PeriodPreset.MonthToDate);
// A monthly import in a daily chart would be nothing but unresolved buckets.
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
var month = Assert.Single(monthly.Buckets);
Assert.Equal((Utc(2026, 8, 31, 22), September19), (month.From, month.To));
Assert.Equal(BucketSize.Week, weekly.Size);
Assert.Equal(BucketSize.Year, coarse.Size);
Assert.Equal([Day(2025, 10, 1), Day(2026, 1, 1)], coarse.Buckets.Select(b => b.FirstDay));
}
[Fact]
public void Auto_keeps_the_length_based_default_when_the_data_is_finer()
{
// Hourly data does not turn a year into 365 bars; the user can still ask for days explicitly.
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Hour);
Assert.Equal(BucketSize.Month, plan.Size);
}
[Fact]
public void Auto_coarsens_until_the_plan_fits_a_smaller_point_limit()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, maxPoints: 10);
Assert.Equal(BucketSize.Week, plan.Size);
Assert.Equal(3, plan.Buckets.Count);
Assert.False(plan.Refused);
}
[Fact]
public void An_explicit_day_bucket_over_400_points_is_refused_with_weeks_suggested()
{
var period = Preset(PeriodPreset.Last24Months);
var plan = BucketPlanner.Plan(period, BucketSize.Day);
Assert.True(plan.Refused);
Assert.Empty(plan.Buckets);
Assert.Equal(BucketSize.Day, plan.Size);
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2024, 10, 1).DayNumber + 1, plan.PointCount);
Assert.Equal(BucketSize.Week, plan.Suggested);
Assert.False(BucketPlanner.Plan(period, BucketSize.Week).Refused);
}
[Fact]
public void A_refusal_suggests_nothing_finer_than_the_data_resolves()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last24Months), BucketSize.Day, ResolutionClass.Month);
Assert.True(plan.Refused);
Assert.Equal(BucketSize.Month, plan.Suggested);
}
[Fact]
public void A_refusal_over_centuries_suggests_years()
{
var period = Custom(Day(1950, 1, 1), Day(2025, 12, 31));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.True(plan.Refused);
Assert.Equal(76 * 12, plan.PointCount);
Assert.Equal(BucketSize.Year, plan.Suggested);
}
[Fact]
public void An_explicit_size_within_the_limit_is_honoured_even_finer_than_the_data()
{
var period = Preset(PeriodPreset.Last12Months);
var plan = BucketPlanner.Plan(period, BucketSize.Day, ResolutionClass.Month);
Assert.False(plan.Refused);
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2025, 10, 1).DayNumber + 1, plan.Buckets.Count);
AssertTiles(period, plan.Buckets);
}
[Fact]
public void Every_size_counts_its_buckets_without_building_them()
{
var periods = new[]
{
Preset(PeriodPreset.MonthToDate),
Preset(PeriodPreset.Last24Months),
Preset(PeriodPreset.PreviousYear),
Custom(Day(2023, 12, 31), Day(2025, 1, 1)),
Preset(PeriodPreset.YearToDate, At(NewYork, 2026, 3, 8, 12, 0), NewYork),
};
foreach (var period in periods)
{
foreach (var size in new[] { BucketSize.Day, BucketSize.Week, BucketSize.Month, BucketSize.Year })
{
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
Assert.Equal(BucketPlanner.CountBuckets(period, size), plan.Buckets.Count);
AssertTiles(period, plan.Buckets);
}
}
}
[Fact]
public void The_current_month_bucket_is_cut_short_at_now_and_names_the_whole_month_to_drill_into()
{
// Drilling into "1 – 19 Sep" as a custom range would compare with 13 – 31 August; the whole month
// compares with 1 – 19 August, like month to date (D-51).
foreach (var preset in new[] { PeriodPreset.MonthToDate, PeriodPreset.YearToDate, PeriodPreset.Last12Months })
{
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Month);
var september = plan.Buckets[^1];
Assert.Equal((Day(2026, 9, 20), Day(2026, 10, 1)), (september.EndDay, september.NominalEndDay));
Assert.True(september.IsCutShort);
Assert.All(plan.Buckets.SkipLast(1), b => Assert.False(b.IsCutShort));
Assert.All(plan.Buckets.SkipLast(1), b => Assert.Null(b.NominalEndDay));
}
}
[Fact]
public void A_week_cut_at_now_names_its_Sunday_and_a_day_is_never_cut_short()
{
var weeks = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Week);
var days = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day);
Assert.Equal(Day(2026, 9, 21), weeks.Buckets[^1].NominalEndDay);
Assert.All(days.Buckets, b => Assert.False(b.IsCutShort));
}
[Fact]
public void The_whole_unit_of_a_cut_bucket_never_reaches_past_the_range_that_was_asked_for()
{
var reachingPastNow = BucketPlanner.Plan(Custom(Day(2026, 9, 1), Day(2026, 9, 25)), BucketSize.Month);
var complete = BucketPlanner.Plan(Custom(Day(2026, 8, 10), Day(2026, 9, 15), At(Berlin, 2026, 10, 1, 12, 0)), BucketSize.Month);
Assert.Equal(Day(2026, 9, 26), Assert.Single(reachingPastNow.Buckets).NominalEndDay);
// A range that ends mid-month by request is not cut short: its last bucket is all it names.
Assert.All(complete.Buckets, b => Assert.False(b.IsCutShort));
Assert.Equal(Day(2026, 9, 16), complete.Buckets[^1].EndDay);
}
[Fact]
public void At_midnight_the_empty_current_month_still_names_the_whole_month()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1)), BucketSize.Month);
Assert.Equal((Day(2026, 10, 2), Day(2026, 11, 1)), (plan.Buckets[^1].EndDay, plan.Buckets[^1].NominalEndDay));
}
[Fact]
public void The_point_limit_must_allow_at_least_one_point()
{
Assert.Throws<ArgumentOutOfRangeException>(() => BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day, maxPoints: 0));
}
[Theory]
[InlineData(ResolutionClass.Hour, BucketSize.Day)]
[InlineData(ResolutionClass.Day, BucketSize.Day)]
[InlineData(ResolutionClass.Week, BucketSize.Week)]
[InlineData(ResolutionClass.Month, BucketSize.Month)]
[InlineData(ResolutionClass.Coarse, BucketSize.Year)]
public void Each_resolution_class_maps_to_the_finest_bucket_it_can_fill(ResolutionClass resolution, BucketSize expected)
{
Assert.Equal(expected, BucketPlanner.MinimumSizeFor(resolution));
}
}
@@ -0,0 +1,319 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// D-42: a category's cost is the bill algorithm run on its members, and only categories that are slices of the
/// bill form the composition. The seed pins both halves — the Strom category bills Netz (the sheet's Kosten), a
/// car-only category bills the car as a view, and the tank, which is billed but in no category, is Uncategorized.
/// </summary>
public sealed class CategoryCoverTests
{
private const int Strom = 101;
private const int WasserCategory = 102;
private const int Heizung = 103;
private const int EAuto = 104;
[Fact]
public void The_seeded_Strom_category_bills_only_Netz()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]);
Assert.Equal([Netz], cover.BilledMeterIds);
Assert.Empty(cover.SeparatelyBilled);
Assert.Empty(cover.FeedInMeterIds);
Assert.Equal([Haus, Auto, Solar1, Solar2], cover.AnalysisOnlyMeterIds);
Assert.False(cover.LiesOutsideBill);
Assert.Equal(MeterTotalsClass.Breakdown, cover.Meters[Auto].Class);
}
[Fact]
public void A_category_holding_only_Auto_bills_Auto_as_an_overlapping_view()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, EAuto, [Auto]);
Assert.Equal([Auto], cover.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, cover.Meters[Auto].Class);
Assert.Equal([Auto], cover.OutsideBillMeterIds);
Assert.True(cover.LiesOutsideBill);
}
[Fact]
public void A_category_holding_the_house_but_not_the_grid_prices_household_use_as_a_view()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, 105, [Haus, Auto]);
Assert.Equal([Haus], cover.BilledMeterIds);
Assert.True(cover.LiesOutsideBill);
}
[Fact]
public void An_expanded_heating_oil_type_member_bills_the_tank_and_not_the_burner()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]);
Assert.Equal([Oeltank], cover.BilledMeterIds);
Assert.Equal([Brenner], cover.AnalysisOnlyMeterIds);
Assert.False(cover.LiesOutsideBill);
}
[Fact]
public void Seeded_categories_split_into_slices_views_and_the_uncategorized_tank()
{
var full = TotalsPolicy.Classify(Meters(), Links());
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Heizung, []),
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
CategoryCover.Compute(full, EAuto, [Auto]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([Heizung, Strom, WasserCategory], report.DisjointCategoryIds);
Assert.Equal([EAuto], report.OverlappingViewIds);
Assert.Empty(report.Overlaps);
Assert.Equal([Oeltank], report.UncategorizedMeterIds);
}
[Fact]
public void Two_categories_billing_the_same_meter_are_both_overlapping_views()
{
var full = TotalsPolicy.Classify(Meters(), Links());
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, 106, [Netz]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([new CategoryOverlap(Strom, 106, [Netz])], report.Overlaps, OverlapComparer.Instance);
Assert.Equal([Strom, 106], report.OverlappingViewIds);
Assert.Equal([WasserCategory], report.DisjointCategoryIds);
Assert.Equal([Netz, Oeltank], report.UncategorizedMeterIds);
}
[Fact]
public void A_category_keeps_containment_through_a_meter_it_left_out()
{
// Wasser → Keller → Garten; a category of Wasser and Garten must not bill the garden on top of the main
// meter just because the basement meter between them is not a member.
var meters = Meters();
meters.Add(Physical(20, "Keller", Water, MeterMode.CumulativeCounter, "m³"));
meters.Add(Physical(21, "Garten", Water, MeterMode.CumulativeCounter, "m³"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)]);
var cover = CategoryCover.Compute(full, WasserCategory, [Wasser, 21]);
Assert.Equal([Wasser], cover.BilledMeterIds);
Assert.Equal([Wasser], cover.Meters[21].ParentIds);
Assert.Equal([21], cover.AnalysisOnlyMeterIds);
}
[Fact]
public void A_separately_billed_heat_pump_stays_billed_in_a_category_without_the_house()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
var withGrid = CategoryCover.Compute(full, Strom, [Netz, 10]);
var heatPumpOnly = CategoryCover.Compute(full, 107, [10]);
Assert.Equal([Netz], withGrid.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(10, Netz, Haus)], withGrid.SeparatelyBilled);
Assert.False(withGrid.LiesOutsideBill);
Assert.Equal([10], heatPumpOnly.CoverMeterIds);
Assert.False(heatPumpOnly.LiesOutsideBill);
// Alone in its category the heat pump is a root of that restricted run, but the bill prices it at its own
// tariff — and so must the category, or its slice would be charged at the grid price.
Assert.Empty(heatPumpOnly.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(10, Netz, Haus)], heatPumpOnly.SeparatelyBilled);
Assert.Equal(BillLineKind.OwnPrice, Assert.Single(heatPumpOnly.Lines).Kind);
}
[Fact]
public void A_strom_slice_prices_the_grid_import_with_the_heat_pump_deducted_as_the_bill_does()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
var strom = CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]);
var line = Assert.Single(strom.Lines);
Assert.Equal(full.LineOf(Netz), line);
Assert.Equal([new BillDeduction(10, 1)], line.Deductions);
}
[Fact]
public void Strom_plus_uncategorized_reconciles_to_the_bill_when_a_heat_pump_is_billed_separately()
{
// The probed trap: pricing Strom's BilledMeterIds as returned books all of Netz, the heat pump is booked again
// as Uncategorized, and the composition exceeds the bill by 100 kWh × 0.30. Priced by lines it reconciles.
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([Strom, WasserCategory, Heizung], report.DisjointCategoryIds);
Assert.Equal([10], report.UncategorizedMeterIds);
var bill = full.Types.Values.Sum(t => Cost(t.Billing.Lines, meters));
var composition = covers.Sum(c => Cost(c.Lines, meters))
+ Cost(report.UncategorizedMeterIds.Select(id => full.LineOf(id)!), meters);
Assert.Equal((300 - 100) * 0.30 + 100 * 0.22 + 10 * 5.0 + 200 * 1.10, bill, 9);
Assert.Equal(bill, composition, 9);
}
[Fact]
public void A_heat_pump_category_beside_strom_completes_the_composition_with_nothing_uncategorized()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, 107, [10]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Empty(report.UncategorizedMeterIds);
Assert.Empty(report.OverlappingViewIds);
Assert.Equal(full.Types.Values.Sum(t => Cost(t.Billing.Lines, meters)), covers.Sum(c => Cost(c.Lines, meters)), 9);
}
[Fact]
public void A_view_outside_the_bill_is_priced_as_its_own_restricted_bill()
{
// House and heat pump without the grid meter: household use is billed here, with the heat pump out of it.
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
var view = CategoryCover.Compute(full, 109, [Haus, 10]);
Assert.True(view.LiesOutsideBill);
Assert.Equal([Haus], view.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(10, Haus, null)], view.SeparatelyBilled);
Assert.Equal([new BillDeduction(10, 1)], view.Lines[0].Deductions);
}
[Fact]
public void A_strom_category_without_the_heat_pump_leaves_the_heat_pump_uncategorized()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([Strom, WasserCategory], report.DisjointCategoryIds);
Assert.Equal([Oeltank, 10], report.UncategorizedMeterIds);
}
[Fact]
public void An_export_member_brings_its_feed_in_credit_into_the_category()
{
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
];
var full = TotalsPolicy.Classify(meters, []);
var cover = CategoryCover.Compute(full, Strom, [20, 21, 22]);
Assert.Equal([20], cover.BilledMeterIds);
Assert.Equal([21], cover.FeedInMeterIds);
Assert.Equal([22], cover.AnalysisOnlyMeterIds);
Assert.Equal([20, 21], cover.CoverMeterIds);
Assert.False(cover.LiesOutsideBill);
}
[Fact]
public void A_category_counting_Summe_Solar_instead_of_its_strings_bills_nothing_extra()
{
var full = TotalsPolicy.Classify(MetersWithOverride((SummeSolar, TotalsOverride.Always)), Links());
var cover = CategoryCover.Compute(full, 108, [Solar1, Solar2, SummeSolar]);
Assert.Empty(cover.CoverMeterIds);
Assert.Equal(MeterTotalsClass.IncludedByOverride, cover.Meters[SummeSolar].Class);
Assert.Equal(MeterTotalsClass.ExcludedByOverride, cover.Meters[Solar1].Class);
}
[Fact]
public void Unknown_member_ids_are_ignored()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, WasserCategory, [Wasser, 404, Wasser]);
Assert.Equal([Wasser], cover.MemberIds);
Assert.Equal([Wasser], cover.BilledMeterIds);
}
/// <summary>
/// One month priced the way costing prices lines: electricity 0.30/kWh, the heat pump's own 0.22/kWh, water
/// 5.00/m³, oil 1.10/L; quantities Haus 500, Netz 300, Auto 50, heat pump 100, Wasser 10, Öltank 200 (the others
/// are never on a line).
/// </summary>
private static double Cost(IEnumerable<BillLine> lines, IReadOnlyList<TotalsMeter> meters)
{
var quantity = new Dictionary<int, double> { [Haus] = 500, [Netz] = 300, [Auto] = 50, [10] = 100, [Wasser] = 10, [Oeltank] = 200 };
var typePrice = new Dictionary<int, double> { [Electricity] = 0.30, [Water] = 5.0, [Oil] = 1.10 };
var ownPrice = new Dictionary<int, double> { [10] = 0.22 };
return lines.Sum(line =>
{
var priced = quantity[line.MeterId] - line.Deductions.Sum(d => d.UnitFactor * quantity[d.MeterId]);
var price = line.Kind == BillLineKind.OwnPrice
? ownPrice[line.MeterId]
: typePrice[meters.Single(m => m.Id == line.MeterId).EnergyTypeId];
return priced * price;
});
}
private sealed class OverlapComparer : IEqualityComparer<CategoryOverlap>
{
public static readonly OverlapComparer Instance = new();
public bool Equals(CategoryOverlap? x, CategoryOverlap? y) =>
x is not null && y is not null
&& x.CategoryId == y.CategoryId && x.OtherCategoryId == y.OtherCategoryId
&& x.SharedMeterIds.SequenceEqual(y.SharedMeterIds);
public int GetHashCode(CategoryOverlap obj) => HashCode.Combine(obj.CategoryId, obj.OtherCategoryId);
}
}
+145
View File
@@ -0,0 +1,145 @@
using MeterVault.Core.Analysis;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// One rule for change figures everywhere (D-08): the absolute difference whenever both values are known,
/// a percentage only against a positive baseline, and "unknown" never read as zero.
/// </summary>
public sealed class ChangeTests
{
[Fact]
public void A_rise_from_a_positive_baseline_has_an_absolute_difference_a_percentage_and_an_upward_direction()
{
var change = Change.Between(120, 100);
Assert.Equal(20, change.Absolute!.Value, 9);
Assert.Equal(20, change.Percent!.Value, 9);
Assert.Equal(1, change.Direction);
Assert.True(change.IsAvailable);
Assert.True(change.PercentApplicable);
}
[Fact]
public void A_fall_is_negative_in_both_figures()
{
var change = Change.Between(75, 100);
Assert.Equal(-25, change.Absolute!.Value, 9);
Assert.Equal(-25, change.Percent!.Value, 9);
Assert.Equal(-1, change.Direction);
}
[Fact]
public void Against_a_zero_baseline_the_percentage_is_not_applicable_but_the_difference_is_shown()
{
// The old dashboard chip read "+0.0 %" here.
var change = Change.Between(120, 0);
Assert.Equal(120, change.Absolute!.Value, 9);
Assert.Null(change.Percent);
Assert.False(change.PercentApplicable);
Assert.Equal(1, change.Direction);
}
[Fact]
public void Against_a_negative_baseline_the_percentage_is_not_applicable()
{
// A credit shrinking from -10 to -5 is a rise of 5; dividing by -10 would call it -50 %.
var change = Change.Between(-5, -10);
Assert.Equal(5, change.Absolute!.Value, 9);
Assert.Null(change.Percent);
Assert.Equal(1, change.Direction);
}
[Fact]
public void A_signed_change_from_positive_to_negative_keeps_its_percentage()
{
var change = Change.Between(-50, 100);
Assert.Equal(-150, change.Absolute!.Value, 9);
Assert.Equal(-150, change.Percent!.Value, 9);
Assert.Equal(-1, change.Direction);
}
[Fact]
public void Equal_values_are_no_change_even_after_floating_point_noise()
{
var exact = Change.Between(100, 100);
var noisy = Change.Between(0.1 + 0.2, 0.3);
Assert.Equal(0, exact.Direction);
Assert.Equal(0, exact.Percent!.Value, 9);
Assert.Equal(0, noisy.Direction);
Assert.NotNull(noisy.Absolute);
}
[Fact]
public void A_baseline_within_the_tolerance_of_zero_counts_as_zero()
{
Assert.Null(Change.Between(5, 1e-12).Percent);
}
[Theory]
[InlineData(null, 100.0)]
[InlineData(100.0, null)]
[InlineData(null, null)]
[InlineData(double.NaN, 100.0)]
[InlineData(100.0, double.PositiveInfinity)]
public void A_missing_or_non_finite_value_makes_the_change_unavailable_not_minus_100_percent(double? current, double? previous)
{
var change = Change.Between(current, previous);
Assert.Equal(Change.Unavailable, change);
Assert.Null(change.Absolute);
Assert.Null(change.Percent);
Assert.Equal(0, change.Direction);
Assert.False(change.IsAvailable);
}
[Fact]
public void A_difference_that_overflows_is_unavailable()
{
Assert.Equal(Change.Unavailable, Change.Between(double.MaxValue, -double.MaxValue));
}
[Fact]
public void A_difference_below_the_callers_display_tolerance_has_no_direction_but_keeps_its_exact_value()
{
// 0.004 € shows as "+0.00 €"; an upward arrow beside it would claim a change nobody can see.
var cents = Change.Between(100.004, 100, tolerance: 0.005);
var noiseOnly = Change.Between(100.004, 100);
Assert.Equal(0, cents.Direction);
Assert.Equal(0.004, cents.Absolute!.Value, 9);
Assert.NotNull(cents.Percent);
Assert.Equal(1, noiseOnly.Direction);
}
[Fact]
public void A_baseline_that_displays_as_zero_under_the_callers_tolerance_has_no_percentage()
{
Assert.Null(Change.Between(5, 0.004, tolerance: 0.005).Percent);
Assert.NotNull(Change.Between(5, 0.004).Percent);
}
[Theory]
[InlineData(-0.001)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void A_negative_or_non_finite_tolerance_is_rejected(double tolerance)
{
Assert.Throws<ArgumentOutOfRangeException>(() => Change.Between(1, 2, tolerance));
}
[Fact]
public void An_observed_zero_now_against_a_positive_baseline_is_a_real_minus_100_percent()
{
var change = Change.Between(0, 80);
Assert.Equal(-80, change.Absolute!.Value, 9);
Assert.Equal(-100, change.Percent!.Value, 9);
Assert.Equal(-1, change.Direction);
}
}
@@ -0,0 +1,832 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Comparisons shift by local calendar units and map the to-date cut-off as a local date plus wall-clock
/// time (D-06). What these pin down: a month to date compares with the same elapsed part of the previous
/// month, 31 March with all of February, 29 February with all of the previous February; a cut-off in a DST
/// gap takes the first instant after it and one in a fold takes the occurrence with now's offset. The same
/// public mapping shifts any instant (D-07) and pairs the current buckets with the comparison's, and it never
/// runs backwards.
/// </summary>
public sealed class ComparisonResolverTests
{
private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37);
private static readonly ComparisonRequest PreviousPeriod = new(ComparisonKind.PreviousPeriod);
private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear);
private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, Berlin);
private static ComparisonPeriod Applicable(ResolvedPeriod current, ComparisonRequest request)
{
var resolution = ComparisonResolver.Resolve(current, request);
Assert.True(resolution.IsApplicable, $"not applicable: {resolution.Reason}");
Assert.Equal(ComparisonUnavailableReason.None, resolution.Reason);
return resolution.Period;
}
private static ComparisonUnavailableReason Reason(ResolvedPeriod current, ComparisonRequest request)
{
var resolution = ComparisonResolver.Resolve(current, request);
Assert.False(resolution.IsApplicable);
Assert.Null(resolution.Period);
return resolution.Reason;
}
[Fact]
public void Month_to_date_compares_with_the_same_elapsed_part_of_the_previous_month()
{
var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod);
Assert.Equal(Day(2026, 8, 1), comparison.FirstDay);
Assert.Equal(Day(2026, 8, 19), comparison.LastDay);
Assert.Equal(Day(2026, 8, 31), comparison.NominalLastDay);
Assert.Equal(Utc(2026, 7, 31, 22), comparison.From);
Assert.Equal(Utc(2026, 8, 19, 12, 37), comparison.To);
Assert.True(comparison.IsCutOff);
Assert.False(comparison.CappedAtNow);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 1), comparison.Shift);
Assert.Equal(ComparisonKind.PreviousPeriod, comparison.Kind);
}
[Fact]
public void Month_to_date_against_the_previous_year_is_the_same_month_to_the_same_wall_clock_time()
{
var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousYear);
Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 19)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal((Utc(2025, 8, 31, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To));
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 1), comparison.Shift);
}
[Fact]
public void Year_to_date_compares_with_the_previous_year_up_to_the_same_moment()
{
var current = Preset(PeriodPreset.YearToDate);
var previousPeriod = Applicable(current, PreviousPeriod);
var previousYear = Applicable(current, PreviousYear);
var named = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2023));
Assert.Equal((Utc(2024, 12, 31, 23), Utc(2025, 9, 19, 12, 37)), (previousPeriod.From, previousPeriod.To));
Assert.Equal(Day(2025, 12, 31), previousPeriod.NominalLastDay);
Assert.Equal(previousPeriod, previousYear with { Kind = ComparisonKind.PreviousPeriod });
Assert.Equal((Utc(2022, 12, 31, 23), Utc(2023, 9, 19, 12, 37)), (named.From, named.To));
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 3), named.Shift);
}
[Fact]
public void The_last_12_months_compare_with_the_12_months_before()
{
var comparison = Applicable(Preset(PeriodPreset.Last12Months), PreviousPeriod);
Assert.Equal(Day(2024, 10, 1), comparison.FirstDay);
Assert.Equal(Day(2025, 9, 30), comparison.NominalLastDay);
Assert.Equal((Utc(2024, 9, 30, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To));
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 12), comparison.Shift);
}
[Fact]
public void The_last_24_months_compare_with_the_24_months_before_or_shift_one_year_for_the_previous_year()
{
var current = Preset(PeriodPreset.Last24Months);
var previousPeriod = Applicable(current, PreviousPeriod);
var previousYear = Applicable(current, PreviousYear);
Assert.Equal(Day(2022, 10, 1), previousPeriod.FirstDay);
Assert.Equal(Utc(2024, 9, 19, 12, 37), previousPeriod.To);
Assert.Equal(Day(2023, 10, 1), previousYear.FirstDay);
Assert.Equal(Utc(2025, 9, 19, 12, 37), previousYear.To);
}
[Fact]
public void A_complete_month_compares_with_the_complete_previous_month()
{
var august = Preset(PeriodPreset.LastMonth);
var july = Applicable(august, PreviousPeriod);
var augustLastYear = Applicable(august, PreviousYear);
Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay));
Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To));
Assert.False(july.IsCutOff);
Assert.Equal((Utc(2025, 7, 31, 22), Utc(2025, 8, 31, 22)), (augustLastYear.From, augustLastYear.To));
}
[Fact]
public void A_whole_month_compares_with_the_whole_previous_month_however_long_each_is()
{
// February (28 days) against January (31 days), not against the 28 days before 1 February.
var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0));
var january = Applicable(february, PreviousPeriod);
Assert.Equal((Day(2026, 1, 1), Day(2026, 1, 31)), (january.FirstDay, january.LastDay));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 1, 31, 23)), (january.From, january.To));
}
[Fact]
public void On_31_March_the_previous_period_is_all_of_February_because_February_has_no_31st()
{
var now = At(Berlin, 2026, 3, 31, 14, 37);
var february = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal((Day(2026, 2, 1), Day(2026, 2, 28)), (february.FirstDay, february.LastDay));
Assert.Equal((Utc(2026, 1, 31, 23), Utc(2026, 2, 28, 23)), (february.From, february.To));
Assert.False(february.IsCutOff);
}
[Theory]
[InlineData(29)]
[InlineData(30)]
public void Late_March_days_February_lacks_also_compare_with_all_of_February(int day)
{
var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, day, 14, 37)), PreviousPeriod);
Assert.Equal(Utc(2026, 2, 28, 23), february.To);
}
[Fact]
public void On_28_March_the_previous_period_stops_on_28_February_at_the_same_wall_clock_time()
{
// March 28 is still winter time and so is February: 14:37 CET both times.
var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 28, 14, 37)), PreviousPeriod);
Assert.Equal(Utc(2026, 2, 28, 13, 37), february.To);
Assert.Equal(Day(2026, 2, 28), february.LastDay);
Assert.True(february.IsCutOff);
}
[Fact]
public void On_29_February_2028_the_previous_year_is_all_of_February_2027()
{
var now = At(Berlin, 2028, 2, 29, 10, 0);
var month = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
var year = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousYear);
Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (month.FirstDay, month.LastDay));
Assert.Equal((Utc(2027, 1, 31, 23), Utc(2027, 2, 28, 23)), (month.From, month.To));
Assert.False(month.IsCutOff);
Assert.Equal((Day(2027, 1, 1), Day(2027, 2, 28)), (year.FirstDay, year.LastDay));
Assert.Equal((Utc(2026, 12, 31, 23), Utc(2027, 2, 28, 23)), (year.From, year.To));
Assert.True(year.IsCutOff);
}
[Fact]
public void A_complete_leap_February_compares_with_the_whole_shorter_February_a_year_earlier()
{
var february2028 = Preset(PeriodPreset.LastMonth, At(Berlin, 2028, 3, 5, 12, 0));
var february2027 = Applicable(february2028, PreviousYear);
Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (february2027.FirstDay, february2027.NominalLastDay));
Assert.Equal(Utc(2027, 2, 28, 23), february2027.To);
}
[Fact]
public void A_cut_off_that_falls_in_the_spring_DST_gap_takes_the_first_instant_after_the_gap()
{
// 29 March 2027 02:30 exists (2027 switches on 28 March); 29 March 2026 02:30 does not.
var now = At(Berlin, 2027, 3, 29, 2, 30);
var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
Assert.Equal(Utc(2026, 3, 29, 1), comparison.To);
Assert.Equal("2026-03-29 03:00 +02:00", Wall(comparison.To, Berlin));
}
[Fact]
public void A_day_shifted_cut_off_into_the_spring_DST_gap_also_lands_on_the_end_of_the_gap()
{
var now = At(Berlin, 2026, 3, 30, 2, 30);
var today = Custom(Day(2026, 3, 30), Day(2026, 3, 30), now);
var yesterday = Applicable(today, PreviousPeriod);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 1), yesterday.Shift);
Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 1)), (yesterday.From, yesterday.To));
}
[Fact]
public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_winter_offset()
{
// Now is 02:30 winter time on 26 October; 25 October 02:30 happened twice.
var now = At(2026, 10, 26, 2, 30, offsetHours: 1);
var today = Custom(Day(2026, 10, 26), Day(2026, 10, 26), now);
var yesterday = Applicable(today, PreviousPeriod);
Assert.Equal(Utc(2026, 10, 25, 1, 30), yesterday.To);
Assert.Equal("2026-10-25 02:30 +01:00", Wall(yesterday.To, Berlin));
}
[Fact]
public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_summer_offset()
{
// 2027 leaves summer time on 31 October, so 25 October 2027 02:30 is summer time (+02:00).
var now = At(Berlin, 2027, 10, 25, 2, 30);
var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
Assert.Equal(Utc(2026, 10, 25, 0, 30), comparison.To);
Assert.Equal("2026-10-25 02:30 +02:00", Wall(comparison.To, Berlin));
}
[Fact]
public void An_ambiguous_wall_time_whose_offsets_do_not_include_nows_takes_the_first_occurrence()
{
var wall = new DateTime(2026, 10, 25, 2, 30, 0, DateTimeKind.Unspecified);
var instant = LocalCalendar.InstantOf(wall, Berlin, TimeSpan.FromHours(5));
Assert.Equal(Utc(2026, 10, 25, 0, 30), instant);
}
[Fact]
public void A_now_in_the_first_pass_of_the_repeated_hour_maps_into_an_ordinary_month_at_the_same_wall_clock_time()
{
var now = At(2026, 10, 25, 2, 30, offsetHours: 2);
var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal("2026-09-25 02:30 +02:00", Wall(september.To, Berlin));
}
[Fact]
public void A_now_in_the_second_pass_of_the_repeated_hour_maps_to_the_end_of_that_hour_in_an_ordinary_month()
{
// By 02:30 winter time the whole first pass (02:00 – 03:00 summer time) has elapsed. Mapping it to
// 02:30 again would compare 3.5 elapsed hours of the day with 2.5, and put the cut before the image
// of 02:45 summer time — a mapping that runs backwards, which matched coverage cannot invert.
var now = At(2026, 10, 25, 2, 30, offsetHours: 1);
var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal("2026-09-25 03:00 +02:00", Wall(september.To, Berlin));
}
[Fact]
public void A_month_shifted_cut_off_into_the_repeated_hour_takes_the_occurrence_with_nows_winter_offset()
{
// 25 November is winter time (+01:00); 25 October 02:30 happened twice. The first occurrence would be
// 00:30 UTC, so only now's offset explains 01:30 UTC.
var now = At(Berlin, 2026, 11, 25, 2, 30);
var october = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal(Utc(2026, 10, 25, 1, 30), october.To);
Assert.Equal("2026-10-25 02:30 +01:00", Wall(october.To, Berlin));
}
[Theory]
[InlineData(2, 0)]
[InlineData(1, 1)]
public void A_cut_off_in_a_repeated_hour_against_a_year_that_repeats_the_same_hour_keeps_its_pass(int offsetHours, int utcHour)
{
// 31 October is the autumn change both in 2027 and in 2021: first pass maps to first, second to second.
var now = At(2027, 10, 31, 2, 30, offsetHours);
var year2021 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2021));
Assert.Equal(Utc(2021, 10, 31, utcHour, 30), year2021.To);
}
[Fact]
public void In_New_York_a_cut_off_in_the_spring_gap_takes_the_first_instant_after_it()
{
// 8 March 2026 skips 02:00 – 03:00; 8 April 02:30 exists.
var now = At(NewYork, 2026, 4, 8, 2, 30);
var march = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod);
Assert.Equal(Utc(2026, 3, 8, 7), march.To);
Assert.Equal("2026-03-08 03:00 -04:00", Wall(march.To, NewYork));
}
[Fact]
public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_standard_offset()
{
// 1 November 2026 repeats 01:00 – 02:00; 1 December 01:30 is EST (-05:00). The first occurrence
// would be 05:30 UTC.
var now = At(NewYork, 2026, 12, 1, 1, 30);
var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod);
Assert.Equal(Utc(2026, 11, 1, 6, 30), november.To);
Assert.Equal(Utc(2026, 11, 1, 4), november.From);
}
[Fact]
public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_daylight_offset()
{
// 2027 falls back on 7 November, so 1 November 2027 01:30 is EDT (-04:00).
var now = At(NewYork, 2027, 11, 1, 1, 30);
var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousYear);
Assert.Equal(Utc(2026, 11, 1, 5, 30), november.To);
}
[Fact]
public void Half_an_hour_into_New_Year_every_to_date_preset_compares_with_the_same_half_hour_before()
{
var now = At(Berlin, 2027, 1, 1, 0, 30);
var december = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
var january2026 = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
var year2026 = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousPeriod);
var year2020 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2020));
var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousPeriod);
Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 11, 30, 23, 30)), (december.From, december.To));
Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 1), Day(2026, 12, 31)), (december.FirstDay, december.LastDay, december.NominalLastDay));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (january2026.From, january2026.To));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (year2026.From, year2026.To));
Assert.Equal(Day(2026, 12, 31), year2026.NominalLastDay);
Assert.Equal((Utc(2019, 12, 31, 23), Utc(2019, 12, 31, 23, 30)), (year2020.From, year2020.To));
Assert.Equal((Day(2025, 2, 1), Day(2026, 1, 31)), (last12.FirstDay, last12.NominalLastDay));
Assert.Equal((Utc(2025, 1, 31, 23), Utc(2025, 12, 31, 23, 30)), (last12.From, last12.To));
Assert.All(new[] { december, january2026, year2026, year2020, last12 }, c => Assert.True(c.IsCutOff));
}
[Fact]
public void Half_an_hour_into_New_Year_last_month_is_December_and_compares_with_all_of_November()
{
var november = Applicable(Preset(PeriodPreset.LastMonth, At(Berlin, 2027, 1, 1, 0, 30)), PreviousPeriod);
Assert.Equal((Utc(2026, 10, 31, 23), Utc(2026, 11, 30, 23)), (november.From, november.To));
Assert.False(november.IsCutOff);
}
[Fact]
public void At_the_midnight_that_starts_a_month_month_to_date_compares_with_an_equally_empty_start_of_the_previous_month()
{
var now = At(Berlin, 2026, 10, 1);
var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousYear);
// Nothing has elapsed on either side; the comparison applies, as it does for the last 12 months.
Assert.Equal((Utc(2026, 8, 31, 22), Utc(2026, 8, 31, 22)), (september.From, september.To));
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 1), Day(2026, 9, 30)), (september.FirstDay, september.LastDay, september.NominalLastDay));
Assert.True(september.IsCutOff);
Assert.Equal(Utc(2025, 9, 30, 22), last12.To);
}
[Fact]
public void A_day_range_starting_on_29_February_compares_from_1_March_because_that_day_has_no_counterpart()
{
var now = At(Berlin, 2028, 6, 1, 12, 0);
var comparison = Applicable(Custom(Day(2028, 2, 29), Day(2028, 3, 5), now), PreviousYear);
Assert.Equal((Day(2027, 3, 1), Day(2027, 3, 5)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal((Utc(2027, 2, 28, 23), Utc(2027, 3, 5, 23)), (comparison.From, comparison.To));
// 29 February alone has nothing to compare with.
Assert.Equal(ComparisonUnavailableReason.Empty, Reason(Custom(Day(2028, 2, 29), Day(2028, 2, 29), now), PreviousYear));
}
[Theory]
[InlineData("2026-03-31", "2026-03-01")]
[InlineData("2026-03-30", "2026-03-01")]
[InlineData("2026-03-29", "2026-03-01")]
[InlineData("2026-03-28", "2026-02-28")]
[InlineData("2026-03-01", "2026-02-01")]
[InlineData("2026-04-01", "2026-03-01")]
public void Shifting_a_day_back_a_month_collapses_the_days_the_target_lacks_onto_its_end(string date, string expected)
{
Assert.Equal(Iso(expected), ComparisonResolver.ShiftDate(Iso(date), new ComparisonShift(ComparisonShiftUnit.Month, 1)));
}
[Fact]
public void Shifting_29_February_back_a_year_lands_on_1_March_and_a_day_shift_is_exact()
{
Assert.Equal(Day(2027, 3, 1), ComparisonResolver.ShiftDate(Day(2028, 2, 29), new ComparisonShift(ComparisonShiftUnit.Year, 1)));
Assert.Equal(Day(2028, 2, 28), ComparisonResolver.ShiftDate(Day(2029, 2, 28), new ComparisonShift(ComparisonShiftUnit.Year, 1)));
Assert.Equal(Day(2027, 3, 31), ComparisonResolver.ShiftDate(Day(2026, 3, 31), new ComparisonShift(ComparisonShiftUnit.Year, -1)));
Assert.Equal(Day(2026, 2, 21), ComparisonResolver.ShiftDate(Day(2026, 3, 3), new ComparisonShift(ComparisonShiftUnit.Day, 10)));
}
[Fact]
public void A_covered_range_that_starts_on_a_day_the_target_month_lacks_starts_where_that_month_ends()
{
var shift = new ComparisonShift(ComparisonShiftUnit.Month, 1);
var offset = TimeSpan.FromHours(2);
var start = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30, 10, 0), shift, Berlin, offset);
var sameDayEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 31, 14, 37), shift, Berlin, offset);
var aprilEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 5, 12, 0), shift, Berlin, offset);
// Coverage of 30 – 31 March has no counterpart in February: its image is empty…
Assert.Equal(Utc(2026, 2, 28, 23), start);
Assert.Equal(start, sameDayEnd);
// …and coverage running on into April matches from 1 March, not from a clamped 28 February.
Assert.Equal(Utc(2026, 3, 5, 11), aprilEnd);
}
[Fact]
public void The_start_of_a_local_day_maps_to_the_start_of_the_target_day()
{
var month = new ComparisonShift(ComparisonShiftUnit.Month, 1);
var year = new ComparisonShift(ComparisonShiftUnit.Year, 1);
Assert.Equal(Utc(2026, 2, 28, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 1), month, Berlin, TimeSpan.FromHours(2)));
Assert.Equal(Utc(2025, 3, 29, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30), year, Berlin, TimeSpan.FromHours(2)));
Assert.Equal(Utc(2026, 10, 1, 4), ComparisonResolver.MapInstant(At(NewYork, 2026, 11, 1), month, NewYork, TimeSpan.FromHours(-5)));
}
[Fact]
public void The_comparison_bounds_are_the_images_of_the_current_bounds_under_the_public_mapping()
{
var cases = new (ResolvedPeriod Current, ComparisonRequest Request)[]
{
(Preset(PeriodPreset.MonthToDate), PreviousPeriod),
(Preset(PeriodPreset.MonthToDate), PreviousYear),
(Preset(PeriodPreset.YearToDate), new ComparisonRequest(ComparisonKind.Year, 2023)),
(Preset(PeriodPreset.Last12Months), PreviousPeriod),
(Preset(PeriodPreset.LastMonth), PreviousPeriod),
(Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod),
(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37)), PreviousPeriod),
(Preset(PeriodPreset.MonthToDate, At(2026, 10, 25, 2, 30, offsetHours: 1)), PreviousPeriod),
(Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0)), PreviousYear),
(Preset(PeriodPreset.MonthToDate, At(NewYork, 2026, 12, 1, 1, 30), NewYork), PreviousPeriod),
};
foreach (var (current, request) in cases)
{
var comparison = Applicable(current, request);
Assert.Equal(comparison.From, comparison.MapInstant(current.From, current.Zone));
Assert.Equal(comparison.To, comparison.MapInstant(current.To, current.Zone));
}
}
public static TheoryData<string> SweepZones => ["Europe/Berlin", "America/New_York", "America/Santiago", "Australia/Lord_Howe"];
[Theory]
[MemberData(nameof(SweepZones))]
public void The_instant_mapping_never_runs_backwards_across_DST_changes_and_short_months(string zoneId)
{
// Matched coverage (D-07) inverts the mapping by bisection, which needs it non-decreasing. A year of
// quarter hours crosses both DST changes, both passes of the repeated hour, and every short month.
var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
ComparisonShift[] shifts =
[
new(ComparisonShiftUnit.Day, 1),
new(ComparisonShiftUnit.Day, 10),
new(ComparisonShiftUnit.Month, 1),
new(ComparisonShiftUnit.Month, 12),
new(ComparisonShiftUnit.Year, 1),
new(ComparisonShiftUnit.Year, -1),
];
TimeSpan[] offsets = [zone.GetUtcOffset(Utc(2026, 1, 15)), zone.GetUtcOffset(Utc(2026, 7, 15))];
foreach (var shift in shifts)
{
foreach (var offset in offsets)
{
var previousInstant = Utc(2026, 1, 1);
var previous = ComparisonResolver.MapInstant(previousInstant, shift, zone, offset);
for (var instant = previousInstant.AddMinutes(15); instant < Utc(2027, 1, 1); instant = instant.AddMinutes(15))
{
var mapped = ComparisonResolver.MapInstant(instant, shift, zone, offset);
if (mapped < previous)
{
Assert.Fail($"{shift} with offset {offset}: {Wall(instant, zone)} maps to {Wall(mapped, zone)}, before {Wall(previousInstant, zone)} → {Wall(previous, zone)}.");
}
previousInstant = instant;
previous = mapped;
}
}
}
}
[Fact]
public void A_cut_comparison_reads_as_a_period_to_date_as_of_its_mapped_cut_off()
{
var current = Preset(PeriodPreset.MonthToDate);
var comparison = Applicable(current, PreviousPeriod);
var period = comparison.ToResolvedPeriod(current);
Assert.Equal(PeriodPreset.Custom, period.Preset);
Assert.Equal((Day(2026, 8, 1), Day(2026, 8, 31)), (period.FirstDay, period.LastDay));
Assert.Equal((comparison.From, comparison.To, comparison.To), (period.From, period.To, period.Now));
Assert.True(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.Same(Berlin, period.Zone);
Assert.Equal(Day(2026, 8, 19), period.EffectiveLastDay());
Assert.Equal(Day(2026, 8, 31), period.NominalLastDay());
var days = BucketPlanner.Plan(period, BucketSize.Day);
var month = Assert.Single(BucketPlanner.Plan(period, BucketSize.Month).Buckets);
Assert.Equal(19, days.Buckets.Count);
Assert.Equal(comparison.To, days.Buckets[^1].To);
Assert.Equal((Day(2026, 8, 20), Day(2026, 9, 1)), (month.EndDay, month.NominalEndDay));
}
[Fact]
public void A_complete_comparison_reads_as_a_complete_period()
{
var current = Preset(PeriodPreset.LastMonth);
var july = Applicable(current, PreviousPeriod).ToResolvedPeriod(current);
Assert.False(july.IsToDate);
Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay));
Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To));
Assert.Equal(current.Now, july.Now);
Assert.Equal(31, BucketPlanner.Plan(july, BucketSize.Day).Buckets.Count);
}
[Fact]
public void A_comparison_cut_at_a_midnight_by_a_missing_day_reads_as_complete_without_an_empty_extra_day()
{
var current = Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0));
var comparison = Applicable(current, PreviousYear);
var period = comparison.ToResolvedPeriod(current);
Assert.False(period.IsToDate);
Assert.Equal((Day(2027, 2, 28), comparison.To), (period.LastDay, period.To));
Assert.Equal(
[Day(2027, 1, 1), Day(2027, 2, 1)],
BucketPlanner.Plan(period, BucketSize.Month).Buckets.Select(b => b.FirstDay));
}
[Fact]
public void At_midnight_the_comparison_keeps_the_same_empty_last_day_as_the_current_period()
{
var now = At(Berlin, 2026, 10, 1);
var last12 = Preset(PeriodPreset.Last12Months, now);
var monthToDate = Preset(PeriodPreset.MonthToDate, now);
var yearBefore = Applicable(last12, PreviousYear).ToResolvedPeriod(last12);
var monthBefore = Applicable(monthToDate, PreviousPeriod).ToResolvedPeriod(monthToDate);
Assert.True(yearBefore.IsToDate);
Assert.Equal(12, BucketPlanner.Plan(yearBefore, BucketSize.Month).Buckets.Count);
var empty = Assert.Single(BucketPlanner.Plan(monthBefore, BucketSize.Day).Buckets);
Assert.Equal((Day(2026, 9, 1), empty.From), (empty.FirstDay, empty.To));
}
[Fact]
public void A_named_later_year_still_running_reads_as_to_date_at_the_real_now()
{
var current = Preset(PeriodPreset.PreviousYear);
var year2026 = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026)).ToResolvedPeriod(current);
Assert.True(year2026.IsToDate);
Assert.Equal((September19, September19), (year2026.To, year2026.Now));
Assert.Equal((Day(2026, 12, 31), Day(2026, 9, 19)), (year2026.LastDay, year2026.EffectiveLastDay()));
}
[Fact]
public void Month_to_date_day_buckets_pair_with_the_same_days_of_the_previous_month()
{
var current = Preset(PeriodPreset.MonthToDate);
var comparison = Applicable(current, PreviousPeriod);
var plan = BucketPlanner.Plan(current, BucketSize.Day);
var pairs = ComparisonResolver.PairBuckets(current, comparison, plan.Buckets);
Assert.Equal(plan.Buckets, pairs.Select(p => p.Current));
Assert.Equal(Enumerable.Range(1, 19).Select(d => Day(2026, 8, d)), pairs.Select(p => p.Comparison.FirstDay));
Assert.All(pairs, p => Assert.Equal(BucketSize.Day, p.Comparison.Size));
Assert.Equal(Utc(2026, 8, 19, 12, 37), pairs[^1].Comparison.To);
AssertTiles(comparison, pairs);
}
[Fact]
public void The_cut_short_current_month_pairs_with_a_cut_short_month_naming_its_whole_unit()
{
var current = Preset(PeriodPreset.Last12Months);
var comparison = Applicable(current, PreviousYear);
var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets);
Assert.Equal(12, pairs.Count);
Assert.All(pairs, p => Assert.Equal(p.Current.FirstDay.AddYears(-1), p.Comparison.FirstDay));
var september = pairs[^1].Comparison;
Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 20), Day(2025, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay));
Assert.Equal(Utc(2025, 9, 19, 12, 37), september.To);
AssertTiles(comparison, pairs);
}
[Fact]
public void Day_buckets_of_February_pair_its_last_day_with_the_rest_of_January_so_the_pairs_add_up()
{
var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0));
var january = Applicable(february, PreviousPeriod);
var pairs = ComparisonResolver.PairBuckets(february, january, BucketPlanner.Plan(february, BucketSize.Day).Buckets);
Assert.Equal(28, pairs.Count);
Assert.Equal((Day(2026, 1, 27), Day(2026, 1, 28)), (pairs[^2].Comparison.FirstDay, pairs[^2].Comparison.EndDay));
Assert.Equal((Day(2026, 1, 28), Day(2026, 2, 1)), (pairs[^1].Comparison.FirstDay, pairs[^1].Comparison.EndDay));
AssertTiles(january, pairs);
}
[Fact]
public void Days_of_March_that_February_lacks_pair_with_empty_buckets()
{
var march = Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37));
var february = Applicable(march, PreviousPeriod);
var pairs = ComparisonResolver.PairBuckets(march, february, BucketPlanner.Plan(march, BucketSize.Day).Buckets);
Assert.Equal(31, pairs.Count);
Assert.Equal((Day(2026, 2, 28), Day(2026, 3, 1)), (pairs[27].Comparison.FirstDay, pairs[27].Comparison.EndDay));
Assert.All(pairs.Skip(28), p => Assert.Equal((p.Comparison.From, p.Comparison.FirstDay), (p.Comparison.To, p.Comparison.EndDay)));
AssertTiles(february, pairs);
}
[Fact]
public void Against_a_named_later_year_still_running_the_buckets_after_now_pair_with_empty_ones()
{
var current = Preset(PeriodPreset.PreviousYear);
var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026));
var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets);
Assert.Equal(12, pairs.Count);
var september = pairs[8].Comparison;
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20), Day(2026, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay));
Assert.Equal(September19, september.To);
Assert.All(pairs.Skip(9), p =>
{
Assert.Equal((September19, September19), (p.Comparison.From, p.Comparison.To));
Assert.Equal(p.Comparison.FirstDay, p.Comparison.EndDay);
Assert.True(p.Comparison.IsCutShort);
});
AssertTiles(comparison, pairs);
}
[Fact]
public void All_history_without_data_has_no_current_period_to_compare()
{
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin);
Assert.Equal(ComparisonUnavailableReason.NoCurrentPeriod, Reason(none, PreviousYear));
}
[Fact]
public void A_comparison_outside_the_supported_years_is_reported_as_out_of_range_never_thrown()
{
var year1900 = Custom(Day(1900, 1, 1), Day(1900, 12, 31));
var yearToDate = Preset(PeriodPreset.YearToDate);
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousYear));
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousPeriod));
foreach (var year in new[] { 1, 1899, 2300, 9999, int.MinValue, int.MaxValue })
{
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, year)));
}
}
private static void AssertTiles(ComparisonPeriod comparison, IReadOnlyList<BucketPair> pairs)
{
Assert.Equal(comparison.From, pairs[0].Comparison.From);
Assert.Equal(comparison.To, pairs[^1].Comparison.To);
for (var i = 1; i < pairs.Count; i++)
{
Assert.Equal(pairs[i - 1].Comparison.To, pairs[i].Comparison.From);
}
}
[Fact]
public void Behind_UTC_the_cut_off_keeps_the_wall_clock_time_across_the_DST_change()
{
// 19 November noon in New York is EST (-5); 19 October noon was EDT (-4).
var now = At(NewYork, 2026, 11, 19, 12, 0);
var october = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod);
Assert.Equal((Utc(2026, 10, 1, 4), Utc(2026, 10, 19, 16)), (october.From, october.To));
}
[Fact]
public void A_custom_month_reaching_past_now_compares_like_month_to_date()
{
var custom = Applicable(Custom(Day(2026, 9, 1), Day(2026, 9, 30)), PreviousPeriod);
var monthToDate = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod);
Assert.Equal(monthToDate, custom);
}
[Fact]
public void A_custom_range_of_days_shifts_back_by_its_own_length()
{
var comparison = Applicable(Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 10), comparison.Shift);
Assert.Equal((Day(2026, 8, 31), Day(2026, 9, 9)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal(Utc(2026, 9, 9, 12, 37), comparison.To);
}
[Fact]
public void A_custom_quarter_compares_with_the_quarter_before()
{
var comparison = Applicable(Custom(Day(2026, 1, 1), Day(2026, 3, 31)), PreviousPeriod);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 3), comparison.Shift);
Assert.Equal((Day(2025, 10, 1), Day(2025, 12, 31)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal(Utc(2025, 12, 31, 23), comparison.To);
}
[Fact]
public void A_complete_year_compares_with_the_year_before_or_any_named_year()
{
var year2025 = Preset(PeriodPreset.PreviousYear);
var year2024 = Applicable(year2025, PreviousPeriod);
var year2020 = Applicable(year2025, new ComparisonRequest(ComparisonKind.Year, 2020));
Assert.Equal((Utc(2023, 12, 31, 23), Utc(2024, 12, 31, 23)), (year2024.From, year2024.To));
Assert.Equal((Day(2020, 1, 1), Day(2020, 12, 31)), (year2020.FirstDay, year2020.LastDay));
}
[Fact]
public void A_named_later_year_still_in_progress_is_capped_at_now()
{
var comparison = Applicable(Preset(PeriodPreset.PreviousYear), new ComparisonRequest(ComparisonKind.Year, 2026));
Assert.Equal(Utc(2025, 12, 31, 23), comparison.From);
Assert.Equal(September19, comparison.To);
Assert.True(comparison.CappedAtNow);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, -1), comparison.Shift);
}
[Fact]
public void Comparisons_that_cannot_apply_say_why()
{
var yearToDate = Preset(PeriodPreset.YearToDate);
Assert.Equal(ComparisonUnavailableReason.NotRequested, Reason(yearToDate, ComparisonRequest.None));
Assert.Equal(
ComparisonUnavailableReason.AllHistory,
Reason(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1)), PreviousYear));
Assert.Equal(
ComparisonUnavailableReason.NotYearAligned,
Reason(Preset(PeriodPreset.MonthToDate), new ComparisonRequest(ComparisonKind.Year, 2024)));
Assert.Equal(
ComparisonUnavailableReason.NotYearAligned,
Reason(Preset(PeriodPreset.Last12Months), new ComparisonRequest(ComparisonKind.Year, 2024)));
Assert.Equal(
ComparisonUnavailableReason.NotYearAligned,
Reason(Custom(Day(2023, 1, 1), Day(2024, 12, 31)), new ComparisonRequest(ComparisonKind.Year, 2020)));
Assert.Equal(ComparisonUnavailableReason.SameYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2026)));
Assert.Equal(
ComparisonUnavailableReason.ComparisonNotYetOccurred,
Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2027)));
Assert.Equal(ComparisonUnavailableReason.MissingYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year)));
Assert.Equal(
ComparisonUnavailableReason.CurrentNotYetOccurred,
Reason(Custom(Day(2027, 1, 1), Day(2027, 1, 31)), PreviousPeriod));
}
[Fact]
public void In_December_the_last_12_months_are_a_calendar_year_and_accept_a_named_year()
{
var current = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 12, 10, 12, 0));
var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2024));
Assert.Equal(Day(2024, 1, 1), comparison.FirstDay);
Assert.Equal(Utc(2024, 12, 10, 11), comparison.To);
}
[Fact]
public void Shifting_off_the_representable_calendar_is_reported_rather_than_thrown()
{
var utc = TimeZoneInfo.Utc;
var yearOne = new ResolvedPeriod(
PeriodPreset.Custom,
new DateOnly(1, 1, 1),
new DateOnly(1, 12, 31),
new DateTimeOffset(1, 1, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2, 1, 1, 0, 0, 0, TimeSpan.Zero),
September19,
IsToDate: false,
ExtendsPastNow: false,
utc);
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearOne, PreviousYear));
}
}
@@ -0,0 +1,167 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// Cost figures fold exactly: the sum of the parts is the same figure — value, status and missing prices — however it
/// is grouped. And the calculator refuses input it cannot price honestly.
/// </summary>
public sealed class CostAmountTests
{
[Fact]
public void The_empty_figure_is_priced_with_no_value()
{
Assert.Equal(CostStatus.Priced, CostAmount.Empty.Status);
Assert.Null(CostAmount.Empty.Cost);
Assert.Equal(BucketStatus.Available, CostAmount.Empty.Availability);
Assert.Empty(CostAmount.Empty.MissingPrices);
var sum = CostAmount.Sum([CostAmount.Empty, CostAmount.Empty]);
Assert.Equal((CostStatus.Priced, (double?)null, false), (sum.Status, sum.Cost, sum.IncludesNotPriced));
}
[Fact]
public void Summing_is_associative_over_lines_and_buckets()
{
// A priced grid line, a water line with a gap, an unpriced tank, a standing charge and a manual cost: the bill
// total must be the same whether the buckets or the lines are added first.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Water, 5, "EUR/m3", D(2025, 3, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 50), "L")],
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))],
manual: [Manual(1, D(2025, 4, 2), 99)]);
var byLine = CostAmount.Sum(result.Lines.Select(l => l.Total).Concat(result.StandingCharges.Select(r => r.Total)).Append(result.ManualCosts.Total));
var byBucket = CostAmount.Sum(result.Totals);
foreach (var figure in new[] { byLine, byBucket })
{
Assert.Equal(result.Total.Cost!.Value, figure.Cost!.Value, 9);
Assert.Equal(result.Total.Status, figure.Status);
Assert.Equal(result.Total.IncludesNotPriced, figure.IncludesNotPriced);
Assert.Equal(result.Total.MissingPrices, figure.MissingPrices);
}
Assert.Equal(CostStatus.Partial, result.Total.Status);
Assert.Equal((6 * 30) + (4 * 50) + (6 * 12) + 99, result.Total.Cost!.Value, 9);
}
[Fact]
public void Missing_prices_merge_into_one_entry_with_their_first_and_last_month()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1)));
var buckets = Buckets(BucketSize.Day, D(2025, 3, 30), D(2025, 5, 2));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 1), "m³")]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((D(2025, 3, 1), D(2025, 5, 1)), (missing.FirstMonth, missing.LastMonth));
// Each day bucket names its own month.
Assert.Equal(D(2025, 4, 1), Assert.Single(result.Totals.At(buckets, D(2025, 4, 17)).MissingPrices).FirstMonth);
}
[Fact]
public void A_unit_mismatch_outranks_a_gap_when_nothing_is_priced()
{
var book = Book(Tariff(1, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.3, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal([CostStatus.PriceGap, CostStatus.UnitMismatch], result.MissingPrices.Select(m => m.Reason));
Assert.Null(result.Total.Cost);
}
[Fact]
public void Availability_reports_the_most_telling_reason_when_nothing_is_known()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 3, 31));
var parts = CostCalculator.Parts(buckets);
var result = Price(
buckets,
book,
[Line(Grid, Electricity, [
CostQuantity.Unknown(parts[0]),
CostQuantity.Unknown(parts[1], BucketStatus.Unresolved),
CostQuantity.Unknown(parts[2], BucketStatus.Pending)])]);
Assert.Equal(BucketStatus.Pending, result.Total.Availability);
Assert.Null(result.Total.Cost);
}
// ---- refusing malformed input ---------------------------------------------------------------------
[Fact]
public void A_quantity_that_is_not_a_part_of_the_request_is_refused()
{
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
// The whole week in one quantity would be priced at one month's price: the caller must split it.
var wholeWeek = new CostQuantity(D(2025, 1, 27), D(2025, 2, 3), 70, BucketStatus.Available);
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [wholeWeek])]));
}
[Fact]
public void A_quantity_given_twice_or_not_finite_is_refused()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, 1), CostQuantity.Known(part, 2)])]));
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, double.NaN)])]));
}
[Fact]
public void Overlapping_buckets_are_refused()
{
var buckets = new[] { Bucket(D(2025, 1, 1), D(2025, 2, 1)), Bucket(D(2025, 1, 15), D(2025, 1, 16), BucketSize.Day) };
Assert.Throws<ArgumentException>(() => CostCalculator.Parts(buckets));
}
[Fact]
public void A_standing_charge_row_names_its_scope_and_a_meter_with_a_line_is_not_a_row()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
// A-18: a meter's own row is for a meter without a line; with one, its charge accrues on the line.
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.Meter, null, null)]));
Assert.Throws<ArgumentException>(() => Price(
buckets, Book(), [Line(Grid, Electricity, Each(buckets, 1))], standing: [StandingChargeScope.ForMeter(Grid, null)]));
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.EnergyType, null, null)]));
Assert.Throws<ArgumentException>(() => Price(
buckets, Book(), [], standing: [StandingChargeScope.Global(null), StandingChargeScope.Global(null)]));
}
[Fact]
public void A_service_period_cannot_end_before_it_starts()
{
Assert.Throws<ArgumentException>(() => new ServicePeriod(D(2025, 2, 1), D(2025, 1, 31)));
}
[Fact]
public void An_empty_request_prices_nothing()
{
var result = Price([], Book(), []);
Assert.Empty(result.Totals);
Assert.Null(result.Total.Cost);
Assert.Equal("EUR", result.Currency);
}
}
@@ -0,0 +1,180 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
using Seed = MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// The calculator prices what <see cref="TotalsPolicy"/> bills (D-34/D-35): on the reference data that is Netz × price,
/// the spreadsheet's <c>Kosten</c>; a separately billed heat pump at its own price; and virtual meters by their named
/// cost rule (D-39).
/// </summary>
public sealed class CostCalculatorBillTests
{
/// <summary>The seeded electricity price history (ReferenceDataImporter).</summary>
private static readonly Tariff[] StromPrices =
[
TypePrice(1, Seed.Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)),
TypePrice(2, Seed.Electricity, 0.44, "EUR/kWh", D(2023, 1, 1)),
TypePrice(3, Seed.Electricity, 0.37, "EUR/kWh", D(2023, 5, 1)),
TypePrice(4, Seed.Electricity, 0.27, "EUR/kWh", D(2023, 11, 1)),
TypePrice(5, Seed.Electricity, 0.36, "EUR/kWh", D(2025, 1, 1)),
TypePrice(6, Seed.Electricity, 0.27, "EUR/kWh", D(2026, 1, 1)),
];
[Fact]
public void The_seeded_Strom_bill_prices_the_grid_import_as_the_sheet_does()
{
// Kosten = Verbrauchskosten − Ersparnis = Netz × €/kWh: Oct 2022 416 × 0.16, Jan 2023 1170 × 0.44, May 2026 827 × 0.27.
var classification = TotalsPolicy.Classify(Seed.Meters(), Seed.Links());
var bill = classification.ForType(Seed.Electricity).Billing;
var netz = new Dictionary<DateOnly, double> { [D(2022, 10, 1)] = 416, [D(2023, 1, 1)] = 1170, [D(2026, 5, 1)] = 827 };
var book = Book(StromPrices);
foreach (var (month, amount) in netz)
{
var buckets = Buckets(BucketSize.Month, month, month.AddMonths(1).AddDays(-1));
var lines = bill.Lines
.Select(l => new CostLine(l.MeterId, Seed.Electricity, l.Kind, "kWh", l.MeterId == Seed.Netz ? Each(buckets, amount) : Each(buckets, 9999)))
.ToList();
var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 9, 19), book, lines));
Assert.Equal([Seed.Netz], result.Lines.Select(l => l.MeterId));
Assert.Equal(amount * SheetPrice(month), result.Total.Cost!.Value, 9);
}
static double SheetPrice(DateOnly month) =>
TariffResolver.ResolveValue(StromPrices, TariffComponent.UnitPrice, Seed.Netz, Seed.Electricity, TariffBook.PriceDate(month));
}
[Fact]
public void A_separately_billed_heat_pump_is_priced_at_its_own_price_out_of_the_grid_import()
{
// SeparatelyBilledSubmeterTests: main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 bill 200 × 0.30 + 100 × 0.22.
const int heatPump = 10;
var meters = Seed.Meters();
meters.Add(Seed.Physical(heatPump, "Wärmepumpe", Seed.Electricity, MeterMode.CumulativeCounter, "kWh"));
var book = Book(TypePrice(1, Seed.Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, heatPump, 0.22, "EUR/kWh", D(2025, 1, 1)));
var classification = TotalsPolicy.Classify(meters, [.. Seed.Links(), new(Seed.Haus, heatPump)], book.HasMeterScopedUnitPrice);
var bill = classification.ForType(Seed.Electricity).Billing;
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var gross = new Dictionary<int, double> { [Seed.Netz] = 300, [heatPump] = 100 };
// The engine's part: net quantities, the deductions taken out of the line they belong to.
var lines = bill.Lines
.Select(l => new CostLine(
l.MeterId,
Seed.Electricity,
l.Kind,
"kWh",
Each(buckets, gross[l.MeterId] - l.Deductions.Sum(d => gross[d.MeterId] * d.UnitFactor))))
.ToList();
var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 1, 1), book, lines));
Assert.Equal([(Seed.Netz, BillLineKind.UnitPrice), (heatPump, BillLineKind.OwnPrice)], result.Lines.Select(l => (l.MeterId, l.Kind)));
Assert.Equal(60, result.Lines[0].Total.Cost!.Value, 9);
Assert.Equal(22, result.Lines[1].Total.Cost!.Value, 9);
Assert.Equal(82, result.Total.Cost!.Value, 9);
}
[Fact]
public void Before_its_own_price_starts_a_subsection_is_billed_inside_its_parent()
{
// The heat pump's own tariff starts in March. In January and February it is not billed separately: the engine
// leaves its energy in the grid import (HasOwnUnitPrice), and its own line has nothing — no gap, no zero.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var grid = Monthly(buckets, m => book.HasOwnUnitPrice(Heater, m) ? 300 - 100 : 300);
var result = Price(buckets, book, [Line(Grid, Electricity, grid), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]);
var heater = result.Lines[1];
Assert.Equal([D(2025, 1, 1), D(2025, 2, 1)], heater.MonthsWithoutOwnPrice);
Assert.Null(heater.Buckets[0].Cost);
Assert.Equal(CostStatus.Priced, heater.Buckets[0].Status);
Assert.Equal(22, heater.Buckets[2].Cost!.Value, 9);
Assert.Empty(result.MissingPrices);
Assert.Equal([90d, 90d, 60d + 22d], result.Totals.Select(t => Math.Round(t.Cost!.Value, 9)));
}
[Fact]
public void An_own_price_line_never_takes_the_type_price()
{
// D-35's OwnPrice means the meter's own price: a line for a meter without one is not priced, not priced at the type's.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var result = Price(buckets, book, [Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]);
Assert.Equal(CostStatus.NotPriced, result.Total.Status);
Assert.Equal((TariffScope.Meter, (int?)Heater), (result.MissingPrices[0].Scope, result.MissingPrices[0].ScopeId));
}
[Fact]
public void Source_costs_and_own_quantity_differ_when_the_sources_have_different_prices()
{
// D-39: a virtual sum of the house (type price 0.30) and a heat pump (own price 0.22), 100 kWh each. Summing the
// sources' costs gives 52; pricing the sum as its own quantity takes the type price: 60. The rule is named.
const int sum = 40;
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
// sourceCosts: each source at its own precedence, without the type's standing charge.
var sourceCosts = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(Heater, Electricity, Each(buckets, 100))]);
// ownQuantity: the virtual meter's evaluated quantity as one line.
var ownQuantity = Price(buckets, book, [Line(sum, Electricity, Each(buckets, 200))]);
Assert.Equal(52, sourceCosts.Total.Cost!.Value, 9);
Assert.Empty(sourceCosts.StandingCharges);
Assert.Equal(60, ownQuantity.Total.Cost!.Value, 9);
}
[Fact]
public void Category_slices_add_up_to_the_bill()
{
// D-42: the composition of disjoint slices reconciles, because a figure is the exact sum of its lines.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Water, 5, "EUR/m3", D(2025, 2, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var always = new ServicePeriod(D(2020, 1, 1));
var bill = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 300), "L")],
standing: [StandingChargeScope.ForEnergyType(Electricity, always)],
manual: [Manual(1, D(2025, 2, 1), 100, categoryId: 7)]);
var slices = new[]
{
bill.Lines[0].Total,
bill.Lines[1].Total,
bill.Lines[2].Total,
bill.StandingCharges[0].Total,
bill.ManualCosts.Total,
};
var composed = CostAmount.Sum(slices);
Assert.Equal(bill.Total.Cost, composed.Cost);
Assert.Equal(bill.Total.Status, composed.Status);
Assert.Equal(bill.Total.MissingPrices, composed.MissingPrices);
Assert.Equal(CostStatus.Partial, bill.Total.Status);
Assert.True(bill.Total.IncludesNotPriced);
Assert.Equal((3 * 30) + (2 * 50) + 36 + 100, bill.Total.Cost!.Value, 9);
Assert.Equal(
[(TariffComponent.UnitPrice, CostStatus.PriceGap, (int?)WaterMeter), (TariffComponent.UnitPrice, CostStatus.NotPriced, OilTank)],
bill.MissingPrices.Select(m => (m.Component, m.Reason, m.MeterId)));
}
}
@@ -0,0 +1,424 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-38 and brief §4.3: "not priced" (no tariff at all), "price gap" (a month without a price inside a priced
/// history), a free zero tariff, and an unknown quantity are four different answers — and none of them is a
/// fabricated zero.
/// </summary>
public sealed class CostCalculatorCoverageTests
{
[Fact]
public void A_line_without_any_tariff_is_not_priced_and_says_from_when()
{
var book = Book();
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 400), "L")]);
Assert.All(result.Lines[0].Buckets, c =>
{
Assert.Equal(CostStatus.NotPriced, c.Status);
Assert.Null(c.Cost);
});
Assert.Equal(CostStatus.NotPriced, result.Total.Status);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, Oil, OilTank, D(2025, 1, 1), D(2025, 3, 1)),
Assert.Single(result.MissingPrices));
Assert.False(Assert.Single(result.MissingPrices).IsCredit);
}
[Fact]
public void A_not_priced_line_is_an_attention_item_not_a_reason_for_a_partial_bill()
{
// D-44: the seeded oil tank has no tariff; the bill is Strom + water, complete for everything priced.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(OilTank, Oil, Each(buckets, 400), "L")]);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.True(result.Total.IncludesNotPriced);
Assert.Equal(60, result.Total.Cost!.Value, 9);
Assert.Equal(BucketStatus.Available, result.Total.Availability);
Assert.Equal(CostStatus.NotPriced, Assert.Single(result.MissingPrices).Reason);
}
[Fact]
public void A_month_before_the_first_price_of_a_priced_scope_is_a_gap_and_the_year_is_partial()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30));
var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 4, 30));
var monthly = Price(months, book, [Line(WaterMeter, Water, Each(months, 10), "m³")]);
var yearly = Price(year, book, [Line(WaterMeter, Water, Each(year, 10), "m³")]);
Assert.Equal(
[CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced],
monthly.Lines[0].Buckets.Select(c => c.Status));
Assert.Null(monthly.Lines[0].Buckets[0].Cost);
Assert.Equal(50, monthly.Lines[0].Buckets[2].Cost!.Value, 9);
var total = yearly.Totals.Single();
Assert.Equal(CostStatus.Partial, total.Status);
Assert.Equal(100, total.Cost!.Value, 9);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.PriceGap, TariffScope.EnergyType, Water, WaterMeter, D(2025, 1, 1), D(2025, 2, 1)),
Assert.Single(total.MissingPrices));
}
[Fact]
public void A_price_that_ended_leaves_a_gap_after_it()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2024, 1, 1), D(2025, 1, 31)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.PriceGap], result.Lines[0].Buckets.Select(c => c.Status));
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((D(2025, 2, 1), D(2025, 3, 1)), (missing.FirstMonth, missing.LastMonth));
}
[Fact]
public void An_explicit_zero_tariff_is_a_valid_zero()
{
var book = Book(TypePrice(1, Water, 0, "EUR/m3", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(0, result.Total.Cost);
Assert.Empty(result.MissingPrices);
}
[Fact]
public void A_known_zero_quantity_needs_no_price()
{
// A meter not yet installed reads a known zero (D-24): the months before the first tariff are no gap.
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, Monthly(buckets, m => m.Month < 3 ? 0 : 10), "m³")]);
Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status));
Assert.Equal([0d, 0d, 50d], result.Lines[0].Buckets.Select(c => Math.Round(c.Cost!.Value, 9)));
Assert.Empty(result.MissingPrices);
}
[Fact]
public void A_month_without_data_needs_no_price()
{
// The seeded grid meter starts in September 2022, its first tariff too: January to August of that year have
// neither data nor a price, and asking for a tariff there would be noise — the cost is unknown for want of data.
var book = Book(TypePrice(1, Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)));
var year = Buckets(BucketSize.Year, D(2022, 1, 1), D(2022, 12, 31));
var result = Price(year, book, [Line(Grid, Electricity, Monthly(year, m => m.Month < 10 ? null : 100))]);
Assert.Empty(result.MissingPrices);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
Assert.Equal(48, result.Total.Cost!.Value, 9);
// A month on its own without data is unknown, not a gap.
var months = Buckets(BucketSize.Month, D(2022, 1, 1), D(2022, 12, 31));
var monthly = Price(months, book, [Line(Grid, Electricity, Monthly(months, m => m.Month < 10 ? null : 100))]);
var january = monthly.Lines[0].Buckets[0];
Assert.Equal(CostStatus.Priced, january.Status);
Assert.Equal(BucketStatus.Missing, january.Availability);
Assert.Null(january.Cost);
}
[Fact]
public void A_line_without_data_or_tariff_reports_nothing_missing()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, Book(), [Line(OilTank, Oil, [.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p))], "L")]);
Assert.Empty(result.MissingPrices);
Assert.False(result.Total.IncludesNotPriced);
Assert.Null(result.Total.Cost);
Assert.Equal(BucketStatus.Missing, result.Total.Availability);
}
[Theory]
[InlineData(BucketStatus.Unresolved)]
[InlineData(BucketStatus.Pending)]
[InlineData(BucketStatus.Invalid)]
public void Data_that_cannot_be_cut_still_needs_its_price(BucketStatus status)
{
// Unresolved, pending or invalid quantities exist — they are only unknown per bucket — so a gap is still a gap.
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, [CostQuantity.Unknown(CostCalculator.Parts(buckets).Single(), status)], "m³")]);
Assert.Equal(CostStatus.PriceGap, result.Total.Status);
Assert.Equal(CostStatus.PriceGap, Assert.Single(result.MissingPrices).Reason);
}
[Fact]
public void Zero_quantities_under_no_tariff_do_not_make_a_line_priced()
{
// Folding the whole line: zero months price nothing, the rest has no tariff — the line is still not priced.
var book = Book();
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(OilTank, Oil, Monthly(buckets, m => m.Month < 3 ? 0 : 400), "L")]);
Assert.Equal(CostStatus.Priced, result.Lines[0].Buckets[0].Status);
Assert.Equal(CostStatus.NotPriced, result.Lines[0].Buckets[2].Status);
Assert.Equal(CostStatus.NotPriced, result.Lines[0].Total.Status);
Assert.Null(result.Lines[0].Total.Cost);
Assert.Equal(D(2025, 3, 1), Assert.Single(result.MissingPrices).FirstMonth);
}
[Fact]
public void A_bucket_holding_an_interval_longer_than_a_month_is_priced_whole_at_one_price()
{
// Review R5 (A-16): a quarterly delta meter leaves every month unresolved; the year holds its intervals whole,
// and one price covers every month, so the year costs its quantity at that price.
var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var unresolved = CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved)).ToList();
var line = Line(Grid, Electricity, unresolved) with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] };
var result = Price(buckets, book, [line]);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(BucketStatus.Available, result.Total.Availability);
Assert.Equal(120, result.Total.Cost!.Value, 9);
Assert.Empty(result.Lines[0].MonthsWithPriceChangeInsideInterval);
// Without a span, the year stays unknown.
var unknown = Price(buckets, book, [Line(Grid, Electricity, unresolved)]);
Assert.Null(unknown.Total.Cost);
Assert.Equal(BucketStatus.Unresolved, unknown.Total.Availability);
}
[Fact]
public void A_price_change_inside_a_whole_bucket_leaves_it_unknown_and_names_its_months()
{
var book = Book(
TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1), D(2025, 7, 31)),
TypePrice(2, Electricity, 0.20, "EUR/kWh", D(2025, 8, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var parts = CostCalculator.Parts(buckets);
var line = Line(Grid, Electricity, [.. parts.Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved))])
with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] };
var result = Price(buckets, book, [line]);
Assert.Null(result.Total.Cost);
Assert.Equal(BucketStatus.Unresolved, result.Total.Availability);
Assert.Equal([.. parts.Select(p => p.Month)], result.Lines[0].MonthsWithPriceChangeInsideInterval);
}
[Fact]
public void A_span_prices_only_the_months_with_data_and_leaves_resolved_buckets_alone()
{
// A tank read from March: January and February have no data and no price, which does not stop the span.
var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var quantities = CostCalculator.Parts(buckets)
.Select(p => p.Month.Month < 3 ? CostQuantity.Unknown(p) : CostQuantity.Unknown(p, BucketStatus.Unresolved))
.ToList();
var line = Line(Grid, Electricity, quantities) with { Spans = [new CostSpan(0, 500, BucketStatus.Partial)] };
var result = Price(buckets, book, [line]);
Assert.Equal(50, result.Total.Cost!.Value, 9);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
// A bucket whose months are all resolved keeps its month-by-month price, span or not.
var months = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var resolved = Line(Grid, Electricity, Each(months, 10)) with { Spans = [new CostSpan(0, 999, BucketStatus.Available)] };
Assert.Equal(1, Price(months, book, [resolved]).Total.Cost!.Value, 9);
}
[Fact]
public void An_unknown_quantity_leaves_the_cost_unknown_but_priced()
{
// Price coverage and quantity availability are separate: March is priced but unmeasured.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var quantities = Monthly(buckets, m => m.Month == 3 ? null : 100);
var result = Price(buckets, book, [Line(Grid, Electricity, quantities)]);
var march = result.Lines[0].Buckets[2];
Assert.Equal(CostStatus.Priced, march.Status);
Assert.Equal(BucketStatus.Missing, march.Availability);
Assert.Null(march.Cost);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(60, result.Total.Cost!.Value, 9);
}
[Theory]
[InlineData(BucketStatus.Unresolved)]
[InlineData(BucketStatus.Invalid)]
[InlineData(BucketStatus.Pending)]
public void A_quantity_that_is_not_a_number_is_never_priced_even_with_an_amount(BucketStatus status)
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
var result = Price(buckets, book, [Line(Grid, Electricity, [new CostQuantity(part.FirstDay, part.EndDay, 100, status)])]);
Assert.Null(result.Total.Cost);
Assert.Equal(status, result.Total.Availability);
}
[Fact]
public void A_partial_quantity_keeps_its_partial_cost()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(part, 50, BucketStatus.Partial)])]);
Assert.Equal(15, result.Total.Cost!.Value, 9);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
}
[Fact]
public void A_part_without_a_quantity_reads_as_missing()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(CostCalculator.Parts(buckets)[0], 100)])]);
Assert.Equal(BucketStatus.Missing, result.Lines[0].Buckets[1].Availability);
Assert.Null(result.Lines[0].Buckets[1].Cost);
Assert.Equal(30, result.Total.Cost!.Value, 9);
}
// ---- where the missing price belongs (D-52) -----------------------------------------------------
[Fact]
public void A_gap_is_suggested_in_the_scope_that_holds_the_price_history()
{
// The meter has its own history, so the gap is filled there, not at the type.
var book = Book(MeterPrice(1, Grid, 0.30, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((TariffScope.Meter, (int?)Grid), (missing.Scope, missing.ScopeId));
}
[Fact]
public void A_gap_under_a_global_price_is_suggested_globally()
{
var book = Book(Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((TariffScope.Global, (int?)null, CostStatus.PriceGap), (missing.Scope, missing.ScopeId, missing.Reason));
}
// ---- D-34: feed-in -------------------------------------------------------------------------------
[Fact]
public void Feed_in_is_credited_on_the_export_line_only_and_kept_apart_from_the_charges()
{
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]);
var import = result.Lines[0].Total;
Assert.Equal(90, import.Usage!.Value, 9);
Assert.Null(import.FeedInCredit);
var export = result.Lines[1].Total;
Assert.Equal(16, export.FeedInCredit!.Value, 9);
Assert.Null(export.Usage);
Assert.Null(export.Charges);
Assert.Equal(-16, export.Cost!.Value, 9);
Assert.Equal(90, result.Total.Charges!.Value, 9);
Assert.Equal(16, result.Total.FeedInCredit!.Value, 9);
Assert.Equal(74, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_missing_feed_in_price_is_an_optional_credit_reported_only_where_there_is_export()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
// No export in January and February, some in March.
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(Export, Electricity, Monthly(buckets, m => m.Month == 3 ? 50 : 0), kind: BillLineKind.FeedIn)]);
var missing = Assert.Single(result.MissingPrices);
Assert.True(missing.IsCredit);
Assert.Equal((CostStatus.NotPriced, D(2025, 3, 1)), (missing.Reason, missing.FirstMonth));
// The bill stays priced: the credit is left out, the charges are complete.
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.True(result.Total.IncludesNotPriced);
Assert.Equal(90, result.Total.Cost!.Value, 9);
Assert.Equal(0d, result.Total.FeedInCredit);
}
[Fact]
public void A_feed_in_price_never_credits_a_billed_line()
{
// Generation and import are not export: only a FeedIn line earns the credit.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 300))]);
Assert.Null(result.Total.FeedInCredit);
Assert.Equal(90, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_feed_in_price_in_the_wrong_unit_is_a_mismatch_on_the_credit()
{
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
FeedInPrice(2, Electricity, 0.08, "EUR/m3", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]);
Assert.Equal(CostStatus.UnitMismatch, result.Lines[1].Total.Status);
Assert.Equal(CostStatus.Partial, result.Total.Status);
Assert.Equal(90, result.Total.Cost!.Value, 9);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((TariffComponent.FeedIn, CostStatus.UnitMismatch, (int?)2), (missing.Component, missing.Reason, missing.TariffId));
}
}
@@ -0,0 +1,107 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-41: a manual cost is booked in full on its PeriodStart local day, when that day is inside the request and not
/// after today; PeriodEnd is informational.
/// </summary>
public sealed class CostCalculatorManualCostTests
{
[Fact]
public void A_manual_cost_is_booked_in_full_on_its_start_day()
{
// A yearly oil delivery invoice dated 3 March: the whole amount in March, nothing spread over the year.
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 3), 1200, categoryId: 4, end: D(2025, 12, 31))]);
var booking = Assert.Single(result.ManualCosts.Bookings);
Assert.Equal(new ManualCostBooking(1, 4, null, D(2025, 3, 3), 2, 1200, false), booking);
Assert.Equal(1200, result.Totals[2].Manual!.Value, 9);
Assert.All(result.Totals.Where((_, i) => i != 2), t => Assert.Null(t.Manual));
Assert.Equal(1200, result.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.Priced, result.Total.Status);
}
[Fact]
public void A_manual_cost_lands_in_the_week_that_contains_its_day()
{
// Mon 31 March – Sun 6 April: a cost dated 31 March belongs to that week, not to a March bucket.
var weeks = Buckets(BucketSize.Week, D(2025, 3, 24), D(2025, 4, 13));
var result = Price(weeks, Book(), [], manual: [Manual(1, D(2025, 3, 31), 80)]);
Assert.Equal(80, result.Totals.At(weeks, D(2025, 3, 31)).Manual!.Value, 9);
Assert.Equal(1, Assert.Single(result.ManualCosts.Bookings).BucketIndex);
}
[Fact]
public void A_manual_cost_dated_after_today_is_not_booked_but_listed()
{
// The September bucket reaches the end of the month, but today is the 19th.
var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) };
var result = Price(
buckets,
Book(),
[],
today: D(2025, 9, 19),
manual: [Manual(1, D(2025, 9, 19), 50), Manual(2, D(2025, 9, 25), 70), Manual(3, D(2025, 8, 31), 90), Manual(4, D(2025, 10, 1), 30)]);
Assert.Equal([1], result.ManualCosts.Bookings.Select(b => b.ManualCostId));
Assert.Equal([2], result.ManualCosts.AfterTodayIds);
Assert.Equal(50, result.Total.Cost!.Value, 9);
}
[Fact]
public void Manual_costs_outside_the_request_are_ignored()
{
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 2, 28), 10), Manual(2, D(2025, 4, 1), 20)]);
Assert.Empty(result.ManualCosts.Bookings);
Assert.Empty(result.ManualCosts.AfterTodayIds);
Assert.Null(result.Total.Cost);
}
[Fact]
public void A_manual_cost_only_instance_still_has_a_cost()
{
// No meter at all: the Heizung manual costs alone make the bill (brief §11, manual-cost-only instance).
var buckets = Buckets(BucketSize.Month, D(2026, 1, 1), D(2026, 2, 28));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2026, 1, 1), 200), Manual(2, D(2026, 2, 1), 180)]);
Assert.Equal([200d, 180d], result.Totals.Select(t => t.Cost!.Value));
Assert.Equal(BucketStatus.Available, result.Total.Availability);
}
[Fact]
public void A_manual_cost_in_another_currency_is_booked_and_flagged()
{
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 5), 40, currency: "USD"), Manual(2, D(2025, 3, 6), 60, currency: "€")]);
Assert.Equal([true, false], result.ManualCosts.Bookings.Select(b => b.CurrencyMismatch));
Assert.True(result.Total.Unverified);
Assert.Equal(100, result.Total.Cost!.Value, 9);
}
[Fact]
public void Manual_costs_add_to_a_bill_exactly_once()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))], manual: [Manual(1, D(2025, 2, 10), 25)]);
Assert.Equal(30 + 30 + 25, result.Total.Cost!.Value, 9);
Assert.Equal(25, result.ManualCosts.Total.Manual!.Value, 9);
Assert.Equal(55, result.Totals[1].Cost!.Value, 9);
}
}
@@ -0,0 +1,268 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-36 and D-37: every month is priced with the price in effect on its 15th, whatever the bucket size, and a price
/// applies only in a unit that fits the meter's normalized unit.
/// </summary>
public sealed class CostCalculatorPricingTests
{
// ---- D-36: the 15th ---------------------------------------------------------------------------
[Fact]
public void A_month_is_priced_with_the_price_in_effect_on_its_15th()
{
// 0.30 from January, 0.40 from 16 March, 0.50 from 15 May: March keeps the old price (the change comes a day
// too late), May already has the new one (in effect on the 15th itself).
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 3, 16)),
TypePrice(3, Electricity, 0.50, "EUR/kWh", D(2025, 5, 15)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 5, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
var costs = result.Lines[0].Buckets.Select(c => c.Cost!.Value).ToList();
Assert.Equal([30, 30, 30, 40, 50], costs.Select(c => Math.Round(c, 9)));
Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status));
Assert.Equal(180, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_price_ending_on_the_14th_leaves_its_month_to_the_next_price()
{
// The old price's ValidTo is inclusive but ends before the 15th; the successor starts on the 15th.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1), D(2025, 2, 14)),
TypePrice(2, Electricity, 0.35, "EUR/kWh", D(2025, 2, 15)));
var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(35, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_week_straddling_a_month_is_priced_with_each_month_s_own_price()
{
// Mon 27 Jan – Sun 2 Feb 2025: five January days at 0.30, two February days at 0.40, 10 kWh a day.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
Assert.Single(buckets);
var parts = CostCalculator.Parts(buckets);
Assert.Equal([(D(2025, 1, 27), D(2025, 2, 1)), (D(2025, 2, 1), D(2025, 2, 3))], parts.Select(p => (p.FirstDay, p.EndDay)));
var result = Price(buckets, book, [Line(Grid, Electricity, Daily(buckets, _ => 10))]);
Assert.Equal((50 * 0.30) + (20 * 0.40), result.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.Priced, result.Total.Status);
}
[Theory]
[InlineData(BucketSize.Day)]
[InlineData(BucketSize.Week)]
[InlineData(BucketSize.Month)]
[InlineData(BucketSize.Year)]
public void The_bucket_size_never_changes_the_total(BucketSize size)
{
// Three price changes (one mid-month), a yearly standing charge and a feed-in credit over 2024 (a leap year):
// day, week, month and year buckets must all give the same bill, and each equal the sum of its month buckets.
var book = Book(
TypePrice(1, Electricity, 0.31, "EUR/kWh", D(2023, 1, 1)),
TypePrice(2, Electricity, 0.28, "EUR/kWh", D(2024, 4, 20)),
TypePrice(3, Electricity, 26.5, "ct/kWh", D(2024, 9, 1)),
FeedInPrice(4, Electricity, 8.2, "ct/kWh", D(2020, 1, 1)),
BasePrice(5, TariffScope.EnergyType, Electricity, 180, "EUR/Jahr", D(2020, 1, 1)));
static double Import(DateOnly d) => 5 + (d.DayOfYear % 7);
static double Exported(DateOnly d) => d.Month is >= 4 and <= 9 ? 3 + (d.Day % 4) : 0.5;
CostResult PriceWith(BucketSize bucketSize)
{
var buckets = Buckets(bucketSize, D(2024, 1, 1), D(2024, 12, 31));
return Price(
buckets,
book,
[Line(Grid, Electricity, Daily(buckets, Import)), Line(Export, Electricity, Daily(buckets, Exported), kind: BillLineKind.FeedIn)],
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))]);
}
var months = PriceWith(BucketSize.Month);
var other = PriceWith(size);
Assert.Equal(months.Total.Cost!.Value, other.Total.Cost!.Value, 6);
Assert.Equal(months.Total.Usage!.Value, other.Total.Usage!.Value, 6);
Assert.Equal(months.Total.StandingCharge!.Value, other.Total.StandingCharge!.Value, 6);
Assert.Equal(180, other.Total.StandingCharge!.Value, 6);
Assert.Equal(months.Total.FeedInCredit!.Value, other.Total.FeedInCredit!.Value, 6);
Assert.Equal(months.Totals.Sum(t => t.Cost!.Value), other.Totals.Sum(t => t.Cost!.Value), 6);
Assert.Equal(CostStatus.Priced, other.Total.Status);
}
[Fact]
public void A_year_is_the_sum_of_its_months_not_a_year_priced_from_one_sample()
{
// The old engine priced a year bucket at the 1 July price × the whole year's quantity.
var book = Book(
TypePrice(1, Electricity, 0.20, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 7, 1)));
var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31));
Assert.Equal(12, CostCalculator.Parts(year).Count);
var yearly = Price(year, book, [Line(Grid, Electricity, Each(year, 100))]);
var monthly = Price(months, book, [Line(Grid, Electricity, Each(months, 100))]);
Assert.Equal((6 * 100 * 0.20) + (6 * 100 * 0.40), yearly.Total.Cost!.Value, 9);
Assert.Equal(monthly.Totals.Sum(t => t.Cost!.Value), yearly.Totals.Single().Cost!.Value, 9);
}
[Fact]
public void A_year_to_date_bucket_ends_in_a_part_of_the_current_month()
{
var buckets = ToDate(PeriodPreset.YearToDate, AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0), BucketSize.Year);
var parts = CostCalculator.Parts(buckets);
Assert.Equal(9, parts.Count);
Assert.Equal((D(2025, 9, 1), D(2025, 9, 20)), (parts[^1].FirstDay, parts[^1].EndDay));
Assert.All(parts, p => Assert.Equal(0, p.BucketIndex));
}
// ---- D-37: units ------------------------------------------------------------------------------
[Fact]
public void A_price_in_cents_per_kWh_is_scaled_to_the_currency()
{
var book = Book(TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(30, result.Total.Cost!.Value, 9);
Assert.False(result.Total.Unverified);
}
[Fact]
public void A_price_per_MWh_prices_a_kWh_meter()
{
var book = Book(TypePrice(1, Electricity, 250, "EUR/MWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 400))]);
Assert.Equal(100, result.Total.Cost!.Value, 9);
}
[Theory]
[InlineData("L", 1000, 950)]
[InlineData("m³", 1, 950)]
[InlineData("m3", 2, 1900)]
public void A_price_per_100_litres_prices_litres_and_cubic_metres(string unit, double quantity, double expected)
{
var book = Book(TypePrice(1, Oil, 95, "EUR/100L", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, quantity), unit)]);
Assert.Equal(expected, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_price_whose_unit_does_not_fit_makes_the_cost_unavailable_for_unit()
{
// A kWh price on a water meter: the old engine charged m³ at the electricity price.
var book = Book(Tariff(7, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]);
var line = result.Lines[0];
Assert.All(line.Buckets, c =>
{
Assert.Equal(CostStatus.UnitMismatch, c.Status);
Assert.Null(c.Cost);
});
var missing = Assert.Single(result.MissingPrices);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.EnergyType, Water, WaterMeter, D(2025, 3, 1), D(2025, 4, 1), 7, TariffUnitIssue.IncompatibleUnit),
missing);
Assert.Null(result.Total.Cost);
}
[Fact]
public void A_mismatching_meter_price_does_not_fall_back_to_the_type_price()
{
// The user's own meter price wins by precedence; applying the type's instead would hide the broken override.
var book = Book(
TypePrice(1, Water, 5, "EUR/m3", D(2025, 1, 1)),
MeterPrice(2, WaterMeter, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal(2, Assert.Single(result.MissingPrices).TariffId);
}
[Fact]
public void A_price_in_another_currency_is_a_mismatch()
{
var book = Book(TypePrice(3, Electricity, 0.25, "USD/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, Assert.Single(result.MissingPrices).Issue);
}
[Fact]
public void An_instance_in_another_currency_prices_its_own_currency()
{
var book = TariffBook.Create([TypePrice(3, Electricity, 25, "Rp./kWh", D(2025, 1, 1))], "CHF");
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal("CHF", result.Currency);
Assert.Equal(25, result.Total.Cost!.Value, 9);
}
[Fact]
public void An_unreadable_unit_applies_at_face_value_with_a_warning()
{
var book = Book(TypePrice(9, Electricity, 0.30, "pauschal", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(60, result.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.True(result.Total.Unverified);
Assert.All(result.Lines[0].Buckets, c => Assert.True(c.Unverified));
Assert.Equal(new TariffWarning(9, TariffComponent.UnitPrice, TariffUnitIssue.Unparseable, Grid, D(2025, 3, 1)), Assert.Single(result.Warnings));
}
[Fact]
public void A_line_is_priced_in_the_unit_it_is_given_not_a_raw_unit()
{
// A burner converted to litres by a fixed rate is priced per litre; its raw unit (h) would mismatch.
var book = Book(Tariff(1, TariffScope.Meter, OilTank, TariffComponent.UnitPrice, 1.10, "EUR/L", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var litres = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "L")]);
var hours = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "h")]);
Assert.Equal(110, litres.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.UnitMismatch, hours.Total.Status);
}
}
@@ -0,0 +1,347 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-40: a standing charge accrues per local day over its scope's service period — once per scope, never once per
/// meter with data (the old engine charged a type's base price for every meter of the type, and a whole month for a
/// month that had barely begun).
/// </summary>
public sealed class CostCalculatorStandingChargeTests
{
private static readonly ServicePeriod Always = new(D(2000, 1, 1));
[Fact]
public void A_type_standing_charge_accrues_once_per_day_however_many_meters_are_billed()
{
const int second = 13;
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100), service: Always), Line(second, Electricity, Each(buckets, 50), service: Always)],
standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
var row = Assert.Single(result.StandingCharges);
Assert.Equal((TariffScope.EnergyType, (int?)Electricity), (row.Scope, row.ScopeId));
Assert.Equal(12, row.Total.StandingCharge!.Value, 9);
// The meters' own lines carry no share of it.
Assert.All(result.Lines, l => Assert.Null(l.Total.StandingCharge));
Assert.Equal(45 + 12, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_month_to_date_accrues_the_days_up_to_today_only()
{
// 19 September at 14:00: nineteen days of a 30-day month, not the whole month.
var now = AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0);
var buckets = ToDate(PeriodPreset.MonthToDate, now, BucketSize.Day);
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1)));
var result = Price(buckets, book, [], today: D(2025, 9, 19), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(19, result.Totals.Count);
Assert.All(result.Totals, t => Assert.Equal(1, t.StandingCharge!.Value, 9));
Assert.Equal(19, result.Total.Cost!.Value, 9);
}
[Fact]
public void In_a_zone_behind_UTC_the_local_month_and_day_decide()
{
// 22:00 on 30 September in New York is already 1 October in UTC: month to date is still September, all 30 days.
var now = AnalysisClock.At(AnalysisClock.NewYork, 2025, 9, 30, 22, 0);
var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, AnalysisClock.NewYork);
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "USD/month", D(2025, 1, 1)));
var result = CostCalculator.Calculate(new CostRequest(
buckets,
PeriodResolver.LocalDate(now, AnalysisClock.NewYork),
TariffBook.Create(book.Tariffs, "USD"),
[],
[StandingChargeScope.ForEnergyType(Electricity, Always)]));
Assert.Equal((D(2025, 9, 1), D(2025, 10, 1)), (CostCalculator.Parts(buckets).Single().FirstDay, CostCalculator.Parts(buckets).Single().EndDay));
Assert.Equal(30, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void Nothing_accrues_after_today_even_inside_a_bucket()
{
var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) };
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1)));
var result = Price(buckets, book, [], today: D(2025, 9, 10), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(10, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_meter_standing_charge_accrues_on_its_line_from_install_to_retire()
{
// Installed 10 March, retired 20 June (inclusive): 22/31 of March, April, May, 20/30 of June, nothing in July.
var service = ServicePeriod.ForMeter(D(2025, 3, 10), D(2025, 6, 20), firstDataDay: D(2025, 3, 12));
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 7, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 0), service: service)]);
var standing = result.Lines[0].Buckets.Select(c => c.StandingCharge!.Value).ToList();
Assert.Equal(10 * 22 / 31d, standing[0], 9);
Assert.Equal(10, standing[1], 9);
Assert.Equal(10, standing[2], 9);
Assert.Equal(10 * 20 / 30d, standing[3], 9);
Assert.Equal(0, standing[4], 9);
Assert.Empty(result.StandingCharges);
}
[Fact]
public void A_service_period_ignores_reading_gaps()
{
// A meter with a month of missing data still pays its standing charge for that month.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Monthly(buckets, m => m.Month == 2 ? null : 100), service: Always)]);
var february = result.Lines[0].Buckets[1];
Assert.Equal(10, february.StandingCharge!.Value, 9);
Assert.Null(february.Usage);
Assert.Equal(10, february.Cost!.Value, 9);
Assert.Equal(BucketStatus.Partial, february.Availability);
}
[Fact]
public void A_yearly_charge_accrues_by_the_days_of_the_year()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 366, "EUR/Jahr", D(2020, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2024, 1, 1), D(2024, 12, 31));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(31, result.Totals[0].StandingCharge!.Value, 9);
Assert.Equal(29, result.Totals[1].StandingCharge!.Value, 9);
Assert.Equal(366, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_quarterly_charge_accrues_by_the_days_of_its_calendar_quarter()
{
// Q1 2025 has 90 days, Q2 91: 90 EUR per quarter is 1 EUR a day in Q1 and 90/91 in Q2.
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 90, "EUR/Quartal", D(2020, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
var charges = result.Totals.Select(t => t.StandingCharge!.Value).ToList();
Assert.Equal(31, charges[0], 9);
Assert.Equal(28, charges[1], 9);
Assert.Equal(31, charges[2], 9);
Assert.Equal(90 * 30 / 91d, charges[3], 9);
Assert.Equal(180, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_daily_charge_is_charged_per_day()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 0.5, "EUR/Tag", D(2020, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(14, result.Total.StandingCharge!.Value, 9);
}
[Theory]
[InlineData(10, 20)]
[InlineData(16, 10)]
public void A_standing_charge_price_change_takes_effect_by_the_15th_rule(int changeDay, double marchPrice)
{
// 10 EUR/month, then 20 from the change day: a change up to the 15th prices all of March at 20 (1–9 March
// included), one on the 16th leaves March at 10. April is 20 either way — per day, month by month.
var book = Book(
BasePrice(1, TariffScope.EnergyType, Electricity, 10, "EUR/Monat", D(2025, 1, 1)),
BasePrice(2, TariffScope.EnergyType, Electricity, 20, "EUR/Monat", D(2025, 3, changeDay)));
var weeks = Buckets(BucketSize.Week, D(2025, 2, 24), D(2025, 4, 6));
var result = Price(weeks, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
// Mon 24 February – Sun 2 March: five February days at 10/28, two March days at the March price.
var straddling = result.Totals.At(weeks, D(2025, 2, 24));
Assert.Equal((5 * 10 / 28d) + (2 * marchPrice / 31d), straddling.StandingCharge!.Value, 9);
Assert.Equal((5 * 10 / 28d) + marchPrice + (6 * 20 / 30d), result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_global_standing_charge_is_its_own_row()
{
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Global, null, 5, "EUR/Monat", D(2025, 1, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100), service: Always)],
standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]);
Assert.Equal([TariffScope.EnergyType, TariffScope.Global], result.StandingCharges.Select(r => r.Scope));
Assert.Equal(5, result.StandingCharges[1].Total.StandingCharge!.Value, 9);
// Each scope's charge is its own: the type's does not replace the global one.
Assert.Equal(30 + 12 + 5, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_meter_s_own_standing_charge_comes_on_top_of_the_type_s()
{
// A heat pump's meter fee next to the supply contract's base price.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)),
MeterPrice(3, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)),
BasePrice(4, TariffScope.Meter, Heater, 8, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 200), service: Always), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice, service: Always)],
standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(8, result.Lines[1].Total.StandingCharge!.Value, 9);
Assert.Equal(22 + 8, result.Lines[1].Total.Cost!.Value, 9);
Assert.Equal(60 + 22 + 8 + 12, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_meter_fee_on_a_meter_without_a_line_is_its_own_row_on_that_meter()
{
// Review R3 (A-18): a PV meter's fee, on a bill that prices only the grid import. It accrues as the PV meter's
// own row, from its install date, not on some other meter's line and not nowhere.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Meter, Export, 2, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100), service: Always)],
standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2025, 2, 1)))]);
var row = Assert.Single(result.StandingCharges);
Assert.Equal((TariffScope.Meter, (int?)Export), (row.Scope, row.ScopeId));
Assert.Equal([0d, 2d, 2d], row.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
Assert.Equal(90 + 4, result.Total.Cost!.Value, 9);
// A gap in the fee's history names the meter it belongs to.
var gap = Price(
Buckets(BucketSize.Month, D(2024, 12, 1), D(2024, 12, 31)),
book,
[],
standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2024, 1, 1)))]);
Assert.Equal((TariffScope.Meter, (int?)Export, (int?)Export), (gap.MissingPrices[0].Scope, gap.MissingPrices[0].ScopeId, gap.MissingPrices[0].MeterId));
}
[Fact]
public void A_scope_without_a_base_price_adds_no_row()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]);
Assert.Empty(result.StandingCharges);
Assert.Empty(result.MissingPrices);
}
[Fact]
public void A_scope_out_of_service_accrues_nothing()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, null)]);
Assert.Equal(0, Assert.Single(result.StandingCharges).Total.StandingCharge);
Assert.Equal(CostStatus.Priced, result.Total.Status);
}
[Fact]
public void A_gap_in_a_standing_charge_history_is_unavailable_for_those_months()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
var row = Assert.Single(result.StandingCharges);
Assert.Equal([CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], row.Buckets.Select(c => c.Status));
Assert.Equal(CostStatus.Partial, row.Total.Status);
Assert.Equal(24, row.Total.StandingCharge!.Value, 9);
Assert.Equal(
new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, TariffScope.EnergyType, Electricity, null, D(2025, 1, 1), D(2025, 2, 1)),
Assert.Single(result.MissingPrices));
}
[Fact]
public void An_unreadable_standing_charge_unit_is_taken_per_month_with_a_warning()
{
var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 12, "pauschal", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(12, result.Total.StandingCharge!.Value, 9);
Assert.True(result.Total.Unverified);
Assert.Equal(TariffUnitIssue.Unparseable, Assert.Single(result.Warnings).Issue);
}
[Fact]
public void A_standing_charge_in_an_unsupported_period_is_a_mismatch()
{
var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 24, "EUR/2 Monate", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((4, TariffUnitIssue.UnsupportedPeriod), (missing.TariffId, missing.Issue));
}
[Fact]
public void The_scope_s_service_period_spans_its_meters()
{
// A retired grid meter and its successor: the type is in service from the first install to now.
var retired = ServicePeriod.ForMeter(D(2020, 5, 1), D(2023, 6, 30), null);
var successor = ServicePeriod.ForMeter(null, null, D(2023, 7, 3));
var neverInService = ServicePeriod.ForMeter(null, null, null);
var span = ServicePeriod.Span([retired, successor, neverInService]);
Assert.Null(neverInService);
Assert.Equal(D(2020, 5, 1), span!.FirstDay);
Assert.Null(span.LastDay);
Assert.Equal(D(2023, 6, 30), ServicePeriod.Span([retired])!.LastDay);
Assert.Null(ServicePeriod.Span([]));
Assert.Null(ServicePeriod.ForMeter(D(2024, 1, 1), D(2023, 1, 1), null));
}
}
@@ -0,0 +1,138 @@
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// The tariff book resolves exactly like <see cref="TariffResolver"/> (meter over type over global, latest start within
/// a scope, both ends inclusive) — but deterministically on ties — and answers the D-35/D-38 questions.
/// </summary>
public sealed class CostingTariffBookTests
{
private static readonly Tariff[] History =
[
Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2023, 1, 1)),
Tariff(2, TariffScope.Global, 99, TariffComponent.UnitPrice, 0.31, "EUR/kWh", D(2023, 6, 1)),
TypePrice(3, Electricity, 0.40, "EUR/kWh", D(2023, 3, 1), D(2023, 12, 31)),
TypePrice(4, Electricity, 0.42, "EUR/kWh", D(2023, 9, 1), D(2023, 10, 31)),
MeterPrice(5, Grid, 0.50, "EUR/kWh", D(2023, 5, 1), D(2023, 5, 31)),
TypePrice(6, Water, 5, "EUR/m3", D(2023, 1, 1)),
Tariff(7, TariffScope.EnergyType, Electricity, TariffComponent.BasePrice, 12, "EUR/Monat", D(2023, 1, 1)),
];
public static TheoryData<int, int, int, int> Dates => new()
{
{ Grid, Electricity, 2, 15 },
{ Grid, Electricity, 4, 15 },
{ Grid, Electricity, 5, 15 },
{ Grid, Electricity, 5, 31 },
{ Grid, Electricity, 6, 1 },
{ Grid, Electricity, 9, 15 },
{ Grid, Electricity, 11, 1 },
{ Grid, Electricity, 12, 31 },
{ WaterMeter, Water, 7, 15 },
{ 77, 5, 7, 15 },
};
[Theory]
[MemberData(nameof(Dates))]
public void Resolves_like_the_tariff_resolver(int meterId, int energyTypeId, int month, int day)
{
var book = Book(History);
var date = D(2023, month, day);
foreach (var component in new[] { TariffComponent.UnitPrice, TariffComponent.BasePrice, TariffComponent.FeedIn })
{
var expected = TariffResolver.Resolve(History, component, meterId, energyTypeId, date);
Assert.Same(expected, book.Resolve(component, meterId, energyTypeId, date));
}
}
[Fact]
public void A_newer_price_in_the_same_scope_overrides_an_older_one_that_still_covers_the_date()
{
var book = Book(History);
Assert.Equal(4, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 9, 15))!.Id);
Assert.Equal(3, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 11, 15))!.Id);
Assert.Equal(2, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2024, 1, 15))!.Id);
}
[Fact]
public void A_tie_on_scope_and_start_goes_to_the_later_entry_whatever_the_load_order()
{
var older = TypePrice(10, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1));
var newer = TypePrice(11, Electricity, 0.35, "EUR/kWh", D(2025, 1, 1));
Assert.Same(newer, Book(older, newer).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15)));
Assert.Same(newer, Book(newer, older).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15)));
}
[Fact]
public void Resolving_in_one_scope_never_falls_back_to_another()
{
var book = Book(History);
Assert.Equal(7, book.ResolveInScope(TariffComponent.BasePrice, TariffScope.EnergyType, Electricity, D(2024, 1, 15))!.Id);
Assert.Null(book.ResolveInScope(TariffComponent.BasePrice, TariffScope.Global, null, D(2024, 1, 15)));
Assert.Null(book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Meter, Grid, D(2023, 6, 15)));
Assert.Equal(2, book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Global, 12345, D(2024, 1, 15))!.Id);
}
[Fact]
public void A_scope_is_priced_when_it_has_a_tariff_at_any_date()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2030, 1, 1)));
Assert.True(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water));
Assert.False(book.HasAny(TariffComponent.UnitPrice, Grid, Electricity));
Assert.False(book.HasAny(TariffComponent.FeedIn, WaterMeter, Water));
}
[Fact]
public void A_tariff_that_ends_before_it_starts_is_left_out()
{
var broken = TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1), D(2025, 1, 31));
var book = Book(broken);
Assert.Empty(book.Tariffs);
Assert.False(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water));
}
[Fact]
public void An_own_price_is_known_per_month_by_the_15th()
{
var book = Book(MeterPrice(1, Heater, 0.22, "EUR/kWh", D(2025, 3, 16)), TypePrice(2, Electricity, 0.30, "EUR/kWh", D(2020, 1, 1)));
Assert.True(book.HasMeterScopedUnitPrice(Heater));
Assert.False(book.HasMeterScopedUnitPrice(Grid));
Assert.False(book.HasOwnUnitPrice(Heater, D(2025, 3, 1)));
Assert.True(book.HasOwnUnitPrice(Heater, D(2025, 4, 30)));
Assert.False(book.HasOwnUnitPrice(Grid, D(2025, 4, 1)));
}
[Fact]
public void Units_are_checked_against_the_instance_currency()
{
var eur = TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1));
var chf = TypePrice(2, Electricity, 0.3, "CHF/kWh", D(2025, 1, 1));
var book = TariffBook.Create([eur, chf], "€");
Assert.Equal(0.3, book.Applicability(eur, "kWh").Convert(30), 12);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, book.Applicability(chf, "kWh").Issue);
Assert.Equal(TariffUnitIssue.IncompatibleUnit, book.Applicability(eur, "m³").Issue);
Assert.Same(book.UnitOf(eur), book.UnitOf(eur));
Assert.Equal(BillingPeriod.Month, book.Accrual(Tariff(3, TariffScope.Global, null, TariffComponent.BasePrice, 5, "EUR/Monat", D(2025, 1, 1))).Period);
}
[Fact]
public void The_price_date_is_the_15th_of_the_month()
{
Assert.Equal(D(2024, 2, 15), TariffBook.PriceDate(D(2024, 2, 29)));
Assert.Equal(D(2025, 12, 15), TariffBook.PriceDate(D(2025, 12, 1)));
}
}
@@ -0,0 +1,153 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// Builders for the cost-calculator tests: real buckets from the period resolver and bucket planner (Berlin), tariffs,
/// and quantities per part. Nothing here reads the wall clock.
/// </summary>
internal static class CostingTestData
{
public const int Electricity = 1;
public const int Water = 2;
public const int Oil = 3;
public const int Grid = 10;
public const int Heater = 11;
public const int Export = 12;
public const int WaterMeter = 20;
public const int OilTank = 30;
/// <summary>A "now" long after every range the tests price, so custom ranges resolve as complete.</summary>
public static readonly DateTimeOffset FarFuture = AnalysisClock.Utc(2040, 1, 1);
public static DateOnly D(int year, int month, int day) => new(year, month, day);
/// <summary>The buckets of the complete range <paramref name="first"/> – <paramref name="last"/> (inclusive), as the planner cuts them.</summary>
public static IReadOnlyList<AnalysisBucket> Buckets(BucketSize size, DateOnly first, DateOnly last)
{
var period = PeriodResolver.Resolve(PeriodPreset.Custom, first, last, FarFuture, AnalysisClock.Berlin);
return Plan(period, size);
}
/// <summary>The buckets of a to-date preset at <paramref name="now"/> (Berlin).</summary>
public static IReadOnlyList<AnalysisBucket> ToDate(PeriodPreset preset, DateTimeOffset now, BucketSize size) =>
Plan(PeriodResolver.Resolve(preset, null, null, now, AnalysisClock.Berlin), size);
/// <summary>A bucket spelled out, for the cases a planner never produces (a bucket reaching past today).</summary>
public static AnalysisBucket Bucket(DateOnly first, DateOnly end, BucketSize size = BucketSize.Month) =>
new(first, end, AnalysisClock.At(AnalysisClock.Berlin, first.Year, first.Month, first.Day),
AnalysisClock.At(AnalysisClock.Berlin, end.Year, end.Month, end.Day), size);
public static Tariff Tariff(
int id,
TariffScope scope,
int? scopeId,
TariffComponent component,
double value,
string unit,
DateOnly from,
DateOnly? to = null) => new()
{
Id = id,
ScopeType = scope,
ScopeId = scopeId,
Component = component,
Value = value,
Unit = unit,
ValidFrom = from,
ValidTo = to,
};
/// <summary>A unit price of an energy type.</summary>
public static Tariff TypePrice(int id, int energyTypeId, double value, string unit, DateOnly from, DateOnly? to = null) =>
Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.UnitPrice, value, unit, from, to);
/// <summary>A meter's own unit price.</summary>
public static Tariff MeterPrice(int id, int meterId, double value, string unit, DateOnly from, DateOnly? to = null) =>
Tariff(id, TariffScope.Meter, meterId, TariffComponent.UnitPrice, value, unit, from, to);
public static Tariff BasePrice(int id, TariffScope scope, int? scopeId, double value, string unit, DateOnly from, DateOnly? to = null) =>
Tariff(id, scope, scopeId, TariffComponent.BasePrice, value, unit, from, to);
public static Tariff FeedInPrice(int id, int energyTypeId, double value, string unit, DateOnly from) =>
Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.FeedIn, value, unit, from);
public static TariffBook Book(params Tariff[] tariffs) => TariffBook.Create(tariffs, "EUR");
/// <summary>Every part's quantity from a per-day amount: the sum over its days.</summary>
public static List<CostQuantity> Daily(IReadOnlyList<AnalysisBucket> buckets, Func<DateOnly, double> perDay) =>
[.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, DaysOf(p).Sum(perDay)))];
/// <summary>Every part's quantity from an amount per whole local month, prorated by days when a part is shorter.</summary>
public static List<CostQuantity> Monthly(IReadOnlyList<AnalysisBucket> buckets, Func<DateOnly, double?> perMonth) =>
[.. CostCalculator.Parts(buckets).Select(p => perMonth(p.Month) is { } amount
? CostQuantity.Known(p, amount * p.Days / DateTime.DaysInMonth(p.Month.Year, p.Month.Month))
: CostQuantity.Unknown(p))];
/// <summary>Every part the same amount.</summary>
public static List<CostQuantity> Each(IReadOnlyList<AnalysisBucket> buckets, double amount) =>
[.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, amount))];
public static CostLine Line(
int meterId,
int energyTypeId,
IReadOnlyList<CostQuantity> quantities,
string unit = "kWh",
BillLineKind kind = BillLineKind.UnitPrice,
ServicePeriod? service = null) =>
new(meterId, energyTypeId, kind, unit, quantities, service);
public static CostResult Price(
IReadOnlyList<AnalysisBucket> buckets,
TariffBook book,
IEnumerable<CostLine> lines,
DateOnly? today = null,
IEnumerable<StandingChargeScope>? standing = null,
IEnumerable<ManualCost>? manual = null) =>
CostCalculator.Calculate(new CostRequest(
buckets,
today ?? D(2039, 12, 31),
book,
[.. lines],
standing is null ? null : [.. standing],
manual is null ? null : [.. manual]));
public static ManualCost Manual(int id, DateOnly start, double amount, int? categoryId = null, int? meterId = null, DateOnly? end = null, string currency = "EUR") => new()
{
Id = id,
CategoryId = categoryId,
MeterId = meterId,
PeriodStart = start,
PeriodEnd = end ?? start,
Amount = amount,
Currency = currency,
};
/// <summary>The cost of the bucket starting on <paramref name="first"/>.</summary>
public static CostAmount At(this IReadOnlyList<CostAmount> cells, IReadOnlyList<AnalysisBucket> buckets, DateOnly first)
{
for (var i = 0; i < buckets.Count; i++)
{
if (buckets[i].FirstDay == first)
{
return cells[i];
}
}
throw new InvalidOperationException($"No bucket starts on {first:yyyy-MM-dd}.");
}
private static IEnumerable<DateOnly> DaysOf(CostPart part) =>
Enumerable.Range(0, part.Days).Select(part.FirstDay.AddDays);
private static IReadOnlyList<AnalysisBucket> Plan(ResolvedPeriod period, BucketSize size)
{
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
Assert.False(plan.Refused);
return plan.Buckets;
}
}
@@ -0,0 +1,410 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Coverage runs (D-13): which stretches of time a meter's data covers, at what resolution, and where
/// it is a known hole. What a bucket may claim — available, partial, unresolved, missing — is decided
/// from these runs, so the shapes the seeded instance really has are pinned here: monthly sheets, a
/// burner read once in twelve years, live snapshots, and the holes a register or sensor can leave. Runs are
/// stored uncapped and say where their last interval starts, so any reader can cut them at its own now (A-04).
/// </summary>
public sealed class CoverageBuilderTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Reading Measured(DateTimeOffset time, double value) =>
new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Measured };
private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id);
private static CoverageRun Run(
DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided, DateTimeOffset last, CoverageGapReason gap = CoverageGapReason.None) =>
new(from.ToUniversalTime(), to.ToUniversalTime(), resolution, divided, gap, last.ToUniversalTime());
private IReadOnlyList<CoverageRun> Coverage(MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, IReadOnlyList<MeterEvent>? events = null) =>
CoverageBuilder.Build(
_engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh" },
Readings = readings,
Events = events ?? [],
TimeZone = zone,
}),
zone);
[Theory]
[InlineData("UTC")]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
public void A_monthly_sheet_is_one_month_run_divided_at_months(string zoneId)
{
var zone = Zone(zoneId);
var readings = Enumerable.Range(0, 12).Select(i => Reading(1, Month(2022, 9).AddMonths(i), 100 * i)).ToList();
var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, zone, readings));
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2022, 9, 1), zone), run.From);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 9, 1), zone), run.To);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 8, 1), zone), run.LastIntervalStart);
Assert.Equal(ResolutionClass.Month, run.Resolution);
Assert.True(run.DividedAtMonths);
Assert.False(run.IsGap);
}
[Fact]
public void A_twelve_year_burner_interval_is_its_own_coarse_run_followed_by_a_month_run()
{
// The seeded burner shape: 0 h on 18.10.2010, then monthly rows from "Oktober 2022" on. The twelve
// years of hours cannot be placed in any month; the months after can.
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[
DayReading(1, Utc(2010, 10, 18), 0),
Reading(1, Month(2022, 10), 7758),
Reading(1, Month(2022, 11), 7785),
Reading(1, Month(2022, 12), 7952),
Reading(1, Month(2023, 1), 8127),
]);
Assert.Equal(
[
Run(Utc(2010, 10, 18), InBerlin(2022, 11, 1), ResolutionClass.Coarse, divided: false, last: Utc(2010, 10, 18)),
Run(InBerlin(2022, 11, 1), InBerlin(2023, 2, 1), ResolutionClass.Month, divided: true, last: InBerlin(2023, 1, 1)),
],
runs);
}
[Fact]
public void Consecutive_intervals_longer_than_a_month_are_each_their_own_run()
{
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[DayReading(1, Utc(2022, 1, 10), 0), DayReading(1, Utc(2022, 3, 10), 100), DayReading(1, Utc(2022, 5, 10), 180)]);
Assert.Equal(
[
Run(Utc(2022, 1, 10), Utc(2022, 3, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 1, 10)),
Run(Utc(2022, 3, 10), Utc(2022, 5, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 3, 10)),
],
runs);
}
[Fact]
public void A_divided_interval_longer_than_a_month_is_month_class_so_month_charts_stay_monthly()
{
// m7 #3, A-03: two six-week gaps between hand readings, each divided at the month starts it crosses.
// Month buckets can place every share, so the data is month-resolution — not coarse, which would make
// auto bucketing jump to years — and no share is finer than the six weeks it was estimated from.
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 8, 1, 9), 700),
Measured(InBerlin(2026, 9, 16, 18), 746),
Measured(InBerlin(2026, 11, 2, 12), 800),
]);
var run = Assert.Single(runs);
Assert.Equal(Run(InBerlin(2026, 8, 1, 9), InBerlin(2026, 11, 2, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 11, 1)), run);
Assert.Equal(BucketSize.Month, BucketPlanner.MinimumSizeFor(run.Resolution));
}
[Fact]
public void Two_divided_intervals_meeting_at_a_month_start_are_classified_each_by_its_own_length()
{
// m7 #3: read on 31 January 12:00, at 00:00 on 1 March, and on 2 April 12:00. Both intervals are
// divided and their shares meet at 1 March like the shares of one interval do; told apart by their
// source intervals they are 29.5 and 32.5 days — month-class, never one 61-day coarse interval.
var rows = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Measured(InBerlin(2026, 1, 31, 12), 100), Measured(InBerlin(2026, 3, 1), 130), Measured(InBerlin(2026, 4, 2, 12), 170)],
TimeZone = Berlin,
});
var run = Assert.Single(CoverageBuilder.Build(rows, Berlin));
Assert.Equal(ResolutionClass.Month, run.Resolution);
Assert.True(run.DividedAtMonths);
Assert.Equal(
[(InBerlin(2026, 1, 31, 12), InBerlin(2026, 3, 1)), (InBerlin(2026, 3, 1), InBerlin(2026, 4, 2, 12))],
rows.Where(r => r.Divided).Select(r => (r.SourceStart!.Value, r.SourceEnd!.Value)).Distinct());
}
[Fact]
public void Month_long_readings_on_the_twentieth_are_divided_and_merge_into_one_month_run()
{
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 6, 20, 12), 100),
Measured(InBerlin(2026, 7, 20, 12), 130),
Measured(InBerlin(2026, 8, 20, 12), 170),
Measured(InBerlin(2026, 9, 20, 12), 200),
]);
Assert.Equal([Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 9, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs);
}
[Fact]
public void A_register_that_stands_still_across_a_month_boundary_stays_in_the_month_aligned_run()
{
// m7 #2: the wallbox read on the 20th did not move from 20 June to 20 July. Zero is zero in both months.
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 5, 20, 12), 100),
Measured(InBerlin(2026, 6, 20, 12), 130),
Measured(InBerlin(2026, 7, 20, 12), 130),
Measured(InBerlin(2026, 8, 20, 12), 160),
]);
var run = Assert.Single(runs);
Assert.True(run.DividedAtMonths);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 6), runs, Berlin, false).Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 7), runs, Berlin, false).Status);
}
[Fact]
public void Burner_hours_that_stand_still_across_a_month_boundary_are_month_aligned_but_moving_hours_are_not()
{
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[
Measured(InBerlin(2026, 5, 20, 12), 100),
Measured(InBerlin(2026, 6, 20, 12), 100),
Measured(InBerlin(2026, 7, 20, 12), 140),
]);
Assert.Equal(
[
Run(InBerlin(2026, 5, 20, 12), InBerlin(2026, 6, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 5, 20, 12)),
Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)),
],
runs);
}
[Fact]
public void Undivided_monthly_intervals_that_straddle_month_starts_are_each_their_own_run()
{
// Burner hours read on the 20th: every interval straddles a month start undivided, so its own two
// readings are the only places it can be cut — a run keeps only its ends, so each interval is one.
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[
Measured(InBerlin(2026, 6, 20, 12), 100),
Measured(InBerlin(2026, 7, 20, 12), 130),
Measured(InBerlin(2026, 8, 20, 12), 170),
]);
Assert.Equal(
[
Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)),
Run(InBerlin(2026, 7, 20, 12), InBerlin(2026, 8, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 7, 20, 12)),
],
runs);
}
[Fact]
public void Undivided_weekly_hours_that_straddle_a_month_start_are_not_divided_at_months()
{
// Burner hours read every Monday. The week across 1 September is one undivided interval, so a
// month bucket cannot split it; a register read on the same days divides that week and stays whole.
Reading[] mondays =
[
Measured(InBerlin(2026, 8, 17, 10), 100),
Measured(InBerlin(2026, 8, 24, 10), 110),
Measured(InBerlin(2026, 8, 31, 10), 125),
Measured(InBerlin(2026, 9, 7, 10), 131),
Measured(InBerlin(2026, 9, 14, 10), 140),
];
var hours = Coverage(MeterMode.RuntimeCounter, Berlin, mondays);
var register = Coverage(MeterMode.CumulativeCounter, Berlin, mondays);
Assert.Equal(
[
Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 8, 31, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 8, 24, 10)),
Run(InBerlin(2026, 8, 31, 10), InBerlin(2026, 9, 7, 10), ResolutionClass.Week, divided: false, last: InBerlin(2026, 8, 31, 10)),
Run(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10)),
],
hours);
Assert.Equal([Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10))], register);
}
[Fact]
public void Midnight_snapshots_are_one_day_run()
{
var snapshots = Enumerable.Range(0, 10).Select(i => Measured(InBerlin(2026, 8, 25).AddDays(i), 100 + i)).ToList();
var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, Berlin, snapshots));
Assert.Equal(Run(InBerlin(2026, 8, 25), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)), run);
}
[Fact]
public void An_unexplained_decrease_is_a_gap_between_covered_runs()
{
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 9, 1), 10),
Measured(InBerlin(2026, 9, 2), 11),
Measured(InBerlin(2026, 9, 3), 12),
Measured(InBerlin(2026, 9, 4), 5),
Measured(InBerlin(2026, 9, 5), 6),
Measured(InBerlin(2026, 9, 6), 7),
]);
Assert.Equal(
[
Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)),
Run(InBerlin(2026, 9, 3), InBerlin(2026, 9, 4), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 3), CoverageGapReason.UnexplainedDecrease),
Run(InBerlin(2026, 9, 4), InBerlin(2026, 9, 6), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 5)),
],
runs);
Assert.True(runs[1].IsGap);
}
[Fact]
public void Consecutive_decreases_are_one_gap_run()
{
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[Measured(InBerlin(2026, 9, 1), 10), Measured(InBerlin(2026, 9, 2), 9), Measured(InBerlin(2026, 9, 3), 8)]);
Assert.Equal(
[Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 2), CoverageGapReason.UnexplainedDecrease)],
runs);
}
[Fact]
public void A_reset_that_does_not_say_where_the_register_stopped_is_a_gap_run()
{
var runs = CoverageBuilder.Build(
_engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 150), Reading(1, Month(2023, 3), 30), Reading(1, Month(2023, 4), 80)],
Events = [Reset(1, Month(2023, 3), newValue: 0)],
}),
TimeZoneInfo.Utc);
Assert.Equal(
[
Run(Month(2023, 1), Month(2023, 3), ResolutionClass.Month, divided: true, last: Month(2023, 2)),
Run(Month(2023, 3), Month(2023, 4), ResolutionClass.Month, divided: false, last: Month(2023, 3), CoverageGapReason.ResetWithoutPrevious),
Run(Month(2023, 4), Month(2023, 5), ResolutionClass.Month, divided: true, last: Month(2023, 4)),
],
runs);
}
[Fact]
public void A_sensor_silence_is_a_gap_run_between_hourly_runs()
{
var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2))
.Append(Measured(Utc(2024, 6, 1, 14), 2))
.Append(Measured(Utc(2024, 6, 1, 14, 5), 2))
.ToList();
var runs = Coverage(MeterMode.InstantRate, TimeZoneInfo.Utc, samples);
Assert.Equal(
[
Run(Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 10, 55)),
Run(Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 14), ResolutionClass.Day, divided: false, last: Utc(2024, 6, 1, 11), CoverageGapReason.SampleGap),
Run(Utc(2024, 6, 1, 14), Utc(2024, 6, 1, 14, 5), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 14)),
],
runs);
}
[Fact]
public void Stored_runs_describe_rows_recorded_after_now_until_a_reader_caps_them()
{
// m7 #4, A-04: "September 2026" imported on the 19th. The stored run still covers September — the
// rebuild's "now" must not be frozen into it — and capping at the reader's now gives the month up.
var now = InBerlin(2026, 9, 19, 12);
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[Reading(1, Month(2026, 7), 100), Reading(1, Month(2026, 8), 130), Reading(1, Month(2026, 9), 170)]);
Assert.Equal([Run(InBerlin(2026, 7, 1), InBerlin(2026, 10, 1), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs);
Assert.Equal(
[new CoverageRun(InBerlin(2026, 7, 1).ToUniversalTime(), InBerlin(2026, 9, 1).ToUniversalTime(), ResolutionClass.Month, DividedAtMonths: true)],
CoverageRuns.CapAt(runs, now, Berlin));
}
[Fact]
public void A_future_reading_costs_only_the_share_that_closes_after_now()
{
// m2 #5: read on the 15th, the 15 September reading stamped ahead (now: 10 September). The August
// share of its interval closed on 1 September and stays covered.
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 7, 15, 8), 100),
Measured(InBerlin(2026, 8, 15, 8), 130),
Measured(InBerlin(2026, 9, 15, 8), 170),
]);
var capped = Assert.Single(CoverageRuns.CapAt(runs, InBerlin(2026, 9, 10, 12), Berlin));
Assert.Equal(InBerlin(2026, 9, 1), capped.To);
}
[Fact]
public void A_tank_that_was_not_drawn_from_across_a_month_boundary_is_month_aligned()
{
var rows = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" },
Events =
[
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 6, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" },
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 7, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" },
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 20, 10), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" },
],
TimeZone = Berlin,
});
var runs = CoverageBuilder.Build(rows, Berlin);
Assert.Equal([true, false], runs.Select(r => r.DividedAtMonths));
}
[Fact]
public void An_opening_balance_and_rows_without_an_interval_cover_nothing()
{
Assert.Empty(Coverage(MeterMode.CumulativeCounter, Berlin, [Measured(InBerlin(2026, 9, 2, 8), 300)]));
Assert.Empty(CoverageBuilder.Build([new Consumption { MeterId = 1, Time = Utc(2026, 9, 1), Amount = 5 }], Berlin));
}
[Fact]
public void Overlapping_rows_never_produce_overlapping_runs()
{
// A nine-day and a seven-day interval that overlap: the earlier one keeps the overlap, the later one
// keeps its own resolution.
Consumption Row(DateTimeOffset from, DateTimeOffset to) =>
new() { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to };
var runs = CoverageBuilder.Build(
[Row(Utc(2026, 1, 5), Utc(2026, 1, 12)), Row(Utc(2026, 1, 1), Utc(2026, 1, 10)), Row(Utc(2026, 1, 2), Utc(2026, 1, 4))],
TimeZoneInfo.Utc);
Assert.Equal(
[
Run(Utc(2026, 1, 1), Utc(2026, 1, 10), ResolutionClass.Month, divided: true, last: Utc(2026, 1, 1)),
Run(Utc(2026, 1, 10), Utc(2026, 1, 12), ResolutionClass.Week, divided: true, last: Utc(2026, 1, 10)),
],
runs);
}
[Fact]
public void Runs_are_in_utc_whatever_offset_the_rows_carry()
{
var from = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.FromHours(2));
var to = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.FromHours(2));
var run = Assert.Single(CoverageBuilder.Build(
[new Consumption { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to }], Berlin));
Assert.Equal(TimeSpan.Zero, run.From.Offset);
Assert.Equal(TimeSpan.Zero, run.To.Offset);
Assert.Equal(TimeSpan.Zero, run.LastIntervalStart!.Value.Offset);
Assert.Equal(from, run.From);
}
}
@@ -0,0 +1,809 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A bucket's status comes from coverage runs, not from sums (D-14). What these pin down: monthly data stays
/// monthly — it resolves months and years and nothing finer; an interval divided at month edges resolves
/// months but not weeks; a long undivided interval leaves every bucket whose edge it crosses unresolved,
/// within the 5 % edge tolerance; outages make exactly the buckets they touch partial or missing; an
/// opening balance, which the caller reports from the rollup flag, keeps its bucket partial (A-01); and a
/// bucket that ends at now is judged on what has happened by then (A-04).
/// </summary>
public sealed class CoverageEvaluatorTests
{
private static readonly IReadOnlyList<CoverageGapReason> NoGaps = [];
private static BucketCoverage Evaluate(AnalysisBucket bucket, params CoverageRun[] runs) =>
CoverageEvaluator.Evaluate(bucket, runs, Berlin, openingBalanceInBucket: false);
// ---- Seeded water meter: imported monthly table rows ---------------------------------------------
[Fact]
public void Monthly_label_data_resolves_its_months_and_years()
{
var water = MonthLabels(2022, 1, 2023, 1);
var december = Evaluate(Month(2022, 12), water);
var year = Evaluate(Year(2022), water);
Assert.Equal(BucketStatus.Available, december.Status);
Assert.Equal(ValueIssue.None, december.Issue);
Assert.Equal(ResolutionClass.Month, december.Resolution);
Assert.Equal(BucketStatus.Available, year.Status);
}
[Fact]
public void Monthly_label_data_is_unresolved_for_days_and_weeks_although_it_covers_them()
{
var water = MonthLabels(2022, 1, 2023, 1);
foreach (var bucket in new[] { Day(2022, 12, 15), Week(2022, 12, 14), Week(2022, 6, 1), Day(2022, 12, 31), Day(2022, 1, 1) })
{
var coverage = Evaluate(bucket, water);
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.Equal(ValueIssue.CoarseResolution, coverage.Issue);
Assert.Equal(1d, coverage.CoveredFraction, 9);
}
}
// ---- A counter read on 1 August and 16 September -------------------------------------------------
public static TheoryData<string> DividedIntervalShapes => ["one coarse divided run", "month shares (D-10 segment bounds)"];
private static CoverageRun[] CounterReadAugustToSeptember(string shape)
{
// As one run the 46-day interval is coarse. As D-10's month shares (1 Aug 09:00 - 1 Sep and
// 1 Sep - 16 Sep 18:00) both shares are month-class and merge into one month-class run. Either way
// the normalizer divided it at the month edge; daily readings surround it.
var interval = shape == "one coarse divided run"
? Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Coarse, divided: true)
: Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Month, divided: true);
return
[
Run(At(2026, 7, 1), At(2026, 8, 1, 9), ResolutionClass.Day),
interval,
Run(At(2026, 9, 16, 18), At(2026, 10, 1), ResolutionClass.Day),
];
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_resolves_both_months(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 8), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 9), runs).Status);
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_still_cannot_say_which_week_it_was_used_in(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
// Mid-August, the week across the month edge (Mon 31 Aug), and the week the closing reading lies in.
foreach (var bucket in new[] { Week(2026, 8, 12), Week(2026, 8, 31), Week(2026, 9, 16), Day(2026, 8, 20) })
{
Assert.Equal(BucketStatus.Unresolved, Evaluate(bucket, runs).Status);
}
// The weeks around it are resolved by the daily readings on either side.
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 7, 15), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 9, 23), runs).Status);
}
// ---- Seeded burner runtime: one 12-year undivided interval ---------------------------------------
[Fact]
public void A_twelve_year_undivided_runtime_interval_leaves_its_months_and_years_unresolved()
{
var brenner = Run(At(2013, 10, 1), At(2025, 10, 1), ResolutionClass.Coarse);
var afterwards = Run(At(2025, 10, 1), At(2026, 6, 1), ResolutionClass.Month, divided: true);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2022), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2022, 3), brenner, afterwards).Status);
// The year it closes in would receive twelve years of runtime.
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), brenner, afterwards).Status);
}
// ---- Seeded oil tank: dipstick levels weeks apart, never divided ---------------------------------
private static readonly CoverageRun[] Tank =
[
Run(At(2025, 9, 20), At(2025, 10, 14), ResolutionClass.Month),
Run(At(2025, 10, 14), At(2025, 11, 28), ResolutionClass.Coarse),
Run(At(2025, 11, 28), At(2025, 12, 20), ResolutionClass.Month),
];
[Fact]
public void A_45_day_undivided_tank_interval_leaves_both_months_it_spans_unresolved()
{
var october = Evaluate(Month(2025, 10), Tank);
var november = Evaluate(Month(2025, 11), Tank);
Assert.Equal(BucketStatus.Unresolved, october.Status);
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
Assert.Equal(BucketStatus.Unresolved, november.Status);
}
[Fact]
public void A_45_day_tank_interval_inside_one_year_does_not_make_the_year_unresolved()
{
// D-14: only an interval crossing a bucket edge misbooks. This one starts and ends in 2025, so the
// year total is right — merely partial, because the tank was only read from 20 September.
var year = Evaluate(Year(2025), Tank);
Assert.Equal(BucketStatus.Partial, year.Status);
Assert.Equal(ValueIssue.PartialCoverage, year.Issue);
Assert.Equal(At(2025, 9, 20), year.FirstCovered);
Assert.Equal(At(2025, 12, 20), year.LastCovered);
}
[Fact]
public void A_month_class_tank_interval_crossing_a_month_edge_leaves_that_month_unresolved()
{
// D-14: the 20 Sep - 14 Oct dipstick interval is booked on 14 October, although 11 of its days lie in
// September. Being no longer than a month does not make an undivided interval land in the right month.
var september = Evaluate(Month(2025, 9), Tank);
Assert.Equal(BucketStatus.Unresolved, september.Status);
Assert.Equal(ValueIssue.CoarseResolution, september.Issue);
Assert.Equal(At(2025, 9, 20), september.FirstCovered);
}
[Fact]
public void Undivided_month_class_intervals_read_mid_month_leave_their_months_unresolved()
{
// Review F1: dipsticks (or burner hours) read on 15 Jan, 14 Feb and 16 Mar. February would receive the
// 15 Jan - 14 Feb draw, 17 of whose 30 days lie in January, so neither month can be told apart.
CoverageRun[] runs =
[
Single(At(2026, 1, 15, 10), At(2026, 2, 14, 10), ResolutionClass.Month),
Single(At(2026, 2, 14, 10), At(2026, 3, 16, 10), ResolutionClass.Month),
];
var february = CoverageEvaluator.Evaluate(Month(2026, 2), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Unresolved, february.Status);
Assert.Equal(ValueIssue.CoarseResolution, february.Issue);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2026, 1), runs).Status);
// The year holds both intervals whole: it is resolved, merely partial (read from 15 January only).
Assert.Equal(BucketStatus.Partial, Evaluate(Year(2026), runs).Status);
}
[Fact]
public void Undivided_month_class_intervals_that_cross_a_month_edge_by_less_than_5_percent_stay_resolved()
{
// The seeded oil tank's dipsticks sit on month-end days: 30 Nov 01:00 - 31 Dec 01:00 crosses into
// December by 23 hours of 31 days (3 %), which D-14 tolerates.
CoverageRun[] runs =
[
Single(At(2025, 11, 30, 1), At(2025, 12, 31, 1), ResolutionClass.Month),
Single(At(2025, 12, 31, 1), At(2026, 1, 31, 1), ResolutionClass.Month),
Single(At(2026, 1, 31, 1), At(2026, 2, 28, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Month(2025, 12), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 1), runs).Status);
}
[Fact]
public void Day_class_intervals_across_a_month_edge_still_resolve_months_on_the_class_rule()
{
// Daily readings at 06:00 cross each month edge by six hours, far inside the tolerance.
var run = Single(At(2026, 1, 31, 6), At(2026, 2, 1, 6), ResolutionClass.Day);
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 1), Berlin));
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 2), Berlin));
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_less_than_5_percent_of_a_year_keeps_both_years_resolved()
{
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 20), ResolutionClass.Month),
Run(At(2024, 12, 20), At(2025, 2, 5), ResolutionClass.Coarse),
Run(At(2025, 2, 5), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Year(2025), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2024, 12), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2025, 1), runs).Status);
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_more_than_5_percent_of_a_year_leaves_both_years_unresolved()
{
// 22 days on each side of a 365-day bucket is 6 %.
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 10), ResolutionClass.Month),
Run(At(2024, 12, 10), At(2025, 1, 23), ResolutionClass.Coarse),
Run(At(2025, 1, 23), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), runs).Status);
}
[Fact]
public void Unresolved_wins_over_partial_because_a_value_too_coarse_to_divide_is_not_a_partial_total()
{
var coverage = Evaluate(Month(2026, 3), Run(At(2026, 3, 10), At(2026, 4, 20), ResolutionClass.Coarse));
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.True(coverage.CoveredFraction < 1);
}
// ---- Hourly samples with a three-day outage ------------------------------------------------------
private static readonly CoverageRun[] HourlyWithOutage =
[
Run(At(2026, 1, 1), At(2026, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
];
[Fact]
public void An_outage_leaves_exactly_the_days_it_touches_partial_or_missing()
{
var notAvailable = new List<(DateOnly Day, BucketStatus Status)>();
for (var day = new DateOnly(2026, 3, 1); day < new DateOnly(2026, 5, 1); day = day.AddDays(1))
{
var coverage = Evaluate(Day(day.Year, day.Month, day.Day), HourlyWithOutage);
if (coverage.Status != BucketStatus.Available)
{
notAvailable.Add((day, coverage.Status));
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
}
}
Assert.Equal(
[
(new DateOnly(2026, 3, 10), BucketStatus.Partial),
(new DateOnly(2026, 3, 11), BucketStatus.Missing),
(new DateOnly(2026, 3, 12), BucketStatus.Missing),
(new DateOnly(2026, 3, 13), BucketStatus.Partial),
],
notAvailable);
}
[Fact]
public void An_outage_makes_its_week_and_month_partial_and_leaves_their_neighbours_available()
{
var outageWeek = Evaluate(Week(2026, 3, 11), HourlyWithOutage);
Assert.Equal(BucketStatus.Partial, outageWeek.Status);
Assert.Equal(ValueIssue.SampleGap, outageWeek.Issue);
Assert.Equal([CoverageGapReason.SampleGap], outageWeek.Gaps);
Assert.Equal(TimeSpan.FromDays(7) - (At(2026, 3, 13, 9) - At(2026, 3, 10, 14)), outageWeek.Covered);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 4), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 18), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Partial, Evaluate(Month(2026, 3), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 4), HourlyWithOutage).Status);
}
[Fact]
public void The_first_outage_day_reports_what_is_covered_and_until_when()
{
var coverage = Evaluate(Day(2026, 3, 10), HourlyWithOutage);
Assert.Equal(TimeSpan.FromHours(14), coverage.Covered);
Assert.Equal(14d / 24, coverage.CoveredFraction, 9);
Assert.Equal(At(2026, 3, 10), coverage.FirstCovered);
Assert.Equal(At(2026, 3, 10, 14), coverage.LastCovered);
Assert.Equal(ResolutionClass.Hour, coverage.Resolution);
}
[Fact]
public void Hourly_data_resolves_the_23_and_25_hour_DST_days()
{
var hourly = Run(At(2026, 3, 1), At(2026, 11, 1), ResolutionClass.Hour);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 3, 29), hourly).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 10, 25), hourly).Status);
}
// ---- Edges and tolerances --------------------------------------------------------------------------
[Fact]
public void A_bucket_nothing_covers_is_missing_not_zero()
{
var coverage = Evaluate(Month(2026, 3));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.FirstCovered);
Assert.Null(coverage.Resolution);
Assert.Equal(0d, coverage.CoveredFraction);
}
[Fact]
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
{
var march = Month(2026, 3);
var coverage = Evaluate(
march,
Run(At(2026, 2, 1), march.From, ResolutionClass.Hour),
Run(march.To, At(2026, 5, 1), ResolutionClass.Hour));
Assert.Equal(BucketStatus.Missing, coverage.Status);
}
[Fact]
public void Coverage_short_by_less_than_a_minute_is_complete_and_by_two_minutes_is_partial()
{
var day = Day(2026, 6, 10);
var jitter = Evaluate(day, Run(day.From.AddSeconds(59), day.To, ResolutionClass.Hour));
var shortfall = Evaluate(day, Run(day.From.AddMinutes(2), day.To, ResolutionClass.Hour));
Assert.Equal(BucketStatus.Available, jitter.Status);
Assert.Equal(BucketStatus.Partial, shortfall.Status);
Assert.Equal(ValueIssue.PartialCoverage, shortfall.Issue);
}
[Fact]
public void Overlapping_runs_are_unioned_rather_than_counted_twice()
{
var day = Day(2026, 6, 10);
var coverage = Evaluate(
day,
Run(day.From, day.From.AddHours(16), ResolutionClass.Hour),
Run(day.From.AddHours(8), day.From.AddHours(20), ResolutionClass.Hour));
Assert.Equal(TimeSpan.FromHours(20), coverage.Covered);
Assert.Equal(BucketStatus.Partial, coverage.Status);
}
[Fact]
public void An_empty_clipped_bucket_is_missing()
{
var instant = At(2026, 9, 1);
var empty = new AnalysisBucket(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1), instant, instant, BucketSize.Day);
Assert.Equal(BucketStatus.Missing, Evaluate(empty, Run(At(2026, 8, 1), At(2026, 10, 1), ResolutionClass.Hour)).Status);
}
[Fact]
public void A_bucket_that_ends_before_it_starts_is_a_caller_error()
{
var reversed = new AnalysisBucket(new DateOnly(2026, 9, 2), new DateOnly(2026, 9, 1), At(2026, 9, 2), At(2026, 9, 1), BucketSize.Day);
Assert.Throws<ArgumentException>(() => Evaluate(reversed));
}
// ---- Opening balances and register discontinuities ------------------------------------------------
[Fact]
public void An_opening_balance_keeps_its_bucket_partial_even_when_readings_cover_the_rest()
{
var firstReading = At(2026, 3, 10, 14);
CoverageRun[] runs = [Run(firstReading, At(2026, 5, 1), ResolutionClass.Hour, divided: true)];
var day = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, openingBalanceInBucket: true);
var month = CoverageEvaluator.Evaluate(Month(2026, 3), runs, Berlin, openingBalanceInBucket: true);
var nextDay = CoverageEvaluator.Evaluate(Day(2026, 3, 11), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Partial, day.Status);
Assert.Equal(ValueIssue.OpeningBalance, day.Issue);
Assert.True(day.OpeningBalance);
Assert.Equal(ValueIssue.OpeningBalance, month.Issue);
Assert.Equal(BucketStatus.Available, nextDay.Status);
Assert.False(nextDay.OpeningBalance);
}
[Fact]
public void A_bucket_holding_only_an_opening_balance_is_partial_not_missing()
{
var coverage = CoverageEvaluator.Evaluate(Day(2026, 3, 10), [], Berlin, openingBalanceInBucket: true);
Assert.Equal(BucketStatus.Partial, coverage.Status);
Assert.Equal(ValueIssue.OpeningBalance, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void An_opening_balance_read_at_midnight_marks_the_day_its_row_is_booked_in_and_not_the_day_before()
{
// A-01: the flag follows the booked row. A first reading describes no time before its midnight, so
// its row stays on 10 March 00:00 (D-11 only moves rows that close an interval), and [From, To)
// files that stamp under 10 March, the day the rollup flags.
var midnight = At(2026, 3, 10);
var rows = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
new Reading { MeterId = 1, Time = midnight, Value = 100, Quality = ReadingQuality.Measured },
new Reading { MeterId = 1, Time = At(2026, 3, 10, 12), Value = 107, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
});
var runs = CoverageBuilder.Build(rows, Berlin);
var opening = Assert.Single(rows, r => r.OpeningBalance);
bool BookedIn(AnalysisBucket bucket) => opening.Time >= bucket.From && opening.Time < bucket.To;
var dayBefore = CoverageEvaluator.Evaluate(Day(2026, 3, 9), runs, Berlin, BookedIn(Day(2026, 3, 9)));
var firstDay = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, BookedIn(Day(2026, 3, 10)));
Assert.Equal(midnight, opening.Time);
Assert.False(dayBefore.OpeningBalance);
Assert.Equal(BucketStatus.Missing, dayBefore.Status);
Assert.True(firstDay.OpeningBalance);
Assert.Equal(BucketStatus.Partial, firstDay.Status);
Assert.Equal(ValueIssue.OpeningBalance, firstDay.Issue);
}
[Theory]
[InlineData(CoverageGapReason.UnexplainedDecrease)]
[InlineData(CoverageGapReason.ResetWithoutPrevious)]
public void A_register_discontinuity_is_named_as_the_reason_a_bucket_is_partial_or_missing(CoverageGapReason reason)
{
CoverageRun[] runs =
[
Run(At(2026, 1, 1), At(2026, 3, 5, 12), ResolutionClass.Day),
Gap(At(2026, 3, 5, 12), At(2026, 3, 9, 12), reason),
Run(At(2026, 3, 9, 12), At(2026, 5, 1), ResolutionClass.Day),
];
var month = Evaluate(Month(2026, 3), runs);
var inside = Evaluate(Day(2026, 3, 7), runs);
Assert.Equal(BucketStatus.Partial, month.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, month.Issue);
Assert.Equal([reason], month.Gaps);
Assert.Equal(BucketStatus.Missing, inside.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, inside.Issue);
}
[Fact]
public void Gap_runs_never_count_as_coverage_and_never_make_a_bucket_unresolved()
{
var coverage = Evaluate(Month(2026, 3), Gap(At(2026, 1, 1), At(2026, 6, 1), CoverageGapReason.SampleGap));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void Several_gap_reasons_are_listed_once_each_most_significant_first()
{
var coverage = Evaluate(
Month(2026, 3),
Run(At(2026, 3, 1), At(2026, 3, 5), ResolutionClass.Hour),
Gap(At(2026, 3, 5), At(2026, 3, 6), CoverageGapReason.SampleGap),
Run(At(2026, 3, 6), At(2026, 3, 10), ResolutionClass.Hour),
Gap(At(2026, 3, 10), At(2026, 3, 11), CoverageGapReason.UnexplainedDecrease),
Run(At(2026, 3, 11), At(2026, 3, 20), ResolutionClass.Hour),
Gap(At(2026, 3, 20), At(2026, 3, 21), CoverageGapReason.SampleGap),
Run(At(2026, 3, 21), At(2026, 4, 1), ResolutionClass.Hour));
Assert.Equal([CoverageGapReason.UnexplainedDecrease, CoverageGapReason.SampleGap], coverage.Gaps);
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
}
[Fact]
public void Only_available_and_partial_buckets_carry_a_number()
{
var water = MonthLabels(2022, 1, 2023, 1);
var available = Evaluate(Month(2022, 12), water).ToValue(14, Provenance.Imported);
var partial = Evaluate(Day(2026, 3, 10), HourlyWithOutage).ToValue(3.5, Provenance.Measured);
var unresolved = Evaluate(Day(2022, 12, 15), water).ToValue(14, Provenance.Imported);
var missing = Evaluate(Month(2030, 1), water).ToValue(0, Provenance.None);
Assert.Equal(new BucketValue(14, BucketStatus.Available, Provenance.Imported), available);
Assert.Equal(new BucketValue(3.5, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap), partial);
Assert.Equal(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), unresolved);
Assert.Equal(BucketValue.Missing(), missing);
}
// ---- Virtual meters: joint evaluation -----------------------------------------------------------------
private static readonly IReadOnlyList<CoverageRun>[] MonthlyAndHourlySources =
[
[MonthLabels(2026, 1, 2026, 8)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
[Fact]
public void A_virtual_bucket_is_as_coarse_as_its_coarsest_source()
{
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), MonthlyAndHourlySources, Berlin, false).Status);
var day = CoverageEvaluator.EvaluateJoint(Day(2026, 4, 10), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, day.Status);
Assert.Equal(ResolutionClass.Month, day.Resolution);
}
[Fact]
public void A_virtual_bucket_is_partial_where_its_sources_only_partly_overlap()
{
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Partial, march.Status);
Assert.Equal(At(2026, 3, 15, 12), march.FirstCovered);
}
[Fact]
public void A_missing_source_makes_the_virtual_bucket_missing()
{
var february = CoverageEvaluator.EvaluateJoint(Month(2026, 2), MonthlyAndHourlySources, Berlin, false);
var august = CoverageEvaluator.EvaluateJoint(Month(2026, 8), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Missing, february.Status);
Assert.Equal(ValueIssue.MissingSource, february.Issue);
Assert.Equal(BucketStatus.Missing, august.Status);
Assert.Equal(ValueIssue.MissingSource, august.Issue);
}
[Fact]
public void Sources_covering_different_halves_of_a_bucket_cover_none_of_it_jointly()
{
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 6, 1), At(2026, 6, 16), ResolutionClass.Hour)],
[Run(At(2026, 6, 16), At(2026, 7, 1), ResolutionClass.Hour)],
];
var june = CoverageEvaluator.EvaluateJoint(Month(2026, 6), sources, Berlin, false);
Assert.Equal(BucketStatus.Missing, june.Status);
Assert.Equal(ValueIssue.NoCoverage, june.Issue);
}
[Fact]
public void A_source_whose_long_interval_is_clipped_by_the_other_sources_start_still_makes_the_bucket_unresolved()
{
// A books 1 January – 15 March in March. B only starts on 1 March, so the joint run is the clipped
// piece 1–15 March, which on its own looks like a harmless interval inside March.
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 6, 1), ResolutionClass.Day)],
[Run(At(2026, 3, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), sources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, march.Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, false).Status);
}
[Fact]
public void A_sources_opening_balance_reaches_the_virtual_bucket()
{
var firstReading = At(2026, 4, 10, 8);
IReadOnlyList<CoverageRun>[] sources =
[
[Run(firstReading, At(2026, 6, 1), ResolutionClass.Hour)],
[Run(At(2026, 1, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var april = CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, openingBalanceInBucket: true);
var may = CoverageEvaluator.EvaluateJoint(Month(2026, 5), sources, Berlin, openingBalanceInBucket: false);
Assert.True(april.OpeningBalance);
Assert.Equal(BucketStatus.Partial, april.Status);
Assert.Equal(ValueIssue.OpeningBalance, april.Issue);
Assert.Equal(BucketStatus.Available, may.Status);
}
[Fact]
public void A_virtual_meter_without_sources_covers_nothing()
{
Assert.Equal(BucketStatus.Missing, CoverageEvaluator.EvaluateJoint(Month(2026, 4), [], Berlin, false).Status);
}
// ---- Zero-length runs, series, now ------------------------------------------------------------------
[Fact]
public void A_zero_length_run_inside_a_bucket_covers_nothing_and_leaves_it_missing()
{
var instant = At(2026, 3, 10, 14);
var coverage = Evaluate(Day(2026, 3, 10), Run(instant, instant, ResolutionClass.Day));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.Resolution);
}
[Fact]
public void A_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
// Unsorted, overlapping runs with holes and gaps, and buckets of every size in no particular order.
CoverageRun[] runs =
[
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
MonthLabels(2025, 1, 2026, 1),
Single(At(2026, 1, 1), At(2026, 2, 20), ResolutionClass.Coarse),
Run(At(2026, 2, 20), At(2026, 3, 10, 14), ResolutionClass.Day, divided: true),
Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour),
Run(At(2026, 6, 5), At(2026, 6, 5), ResolutionClass.Hour),
];
var buckets = new List<AnalysisBucket> { Year(2025), Month(2026, 3), Week(2026, 3, 11) };
for (var day = new DateOnly(2026, 6, 10); day >= new DateOnly(2025, 12, 20); day = day.AddDays(-1))
{
buckets.Add(Day(day.Year, day.Month, day.Day));
}
buckets.Add(Month(2026, 1));
var flags = buckets.Select(b => b.FirstDay == new DateOnly(2026, 3, 13)).ToList();
var series = CoverageEvaluator.EvaluateSeries(buckets, runs, Berlin, flags);
Assert.Equal(buckets.Count, series.Count);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.Evaluate(buckets[i], runs, Berlin, flags[i]);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_joint_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
IReadOnlyList<CoverageRun>[] sources =
[
[MonthLabels(2026, 1, 2026, 8), Gap(At(2026, 8, 1), At(2026, 8, 3), CoverageGapReason.ResetWithoutPrevious)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
var buckets = Enumerable.Range(1, 9).Select(m => Month(2026, m)).Concat([Day(2026, 4, 10), Week(2026, 3, 16)]).Reverse().ToList();
var series = CoverageEvaluator.EvaluateJointSeries(buckets, sources, Berlin, openingBalanceInBucket: null);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.EvaluateJoint(buckets[i], sources, Berlin, openingBalanceInBucket: false);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_series_needs_one_opening_balance_flag_per_bucket()
{
Assert.Throws<ArgumentException>(() =>
CoverageEvaluator.EvaluateSeries([Day(2026, 3, 1), Day(2026, 3, 2)], [], Berlin, [true]));
}
[Fact]
public void A_live_meters_day_and_month_to_date_are_complete_up_to_its_last_sample()
{
// m2 #9: five-minute samples, the last at 09:55, now 10:00. The five minutes since cannot have a row yet.
var now = At(2026, 9, 19, 10);
CoverageRun[] samples = [Run(At(2026, 8, 1), At(2026, 9, 19, 9, 55), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), samples, Berlin, false, now);
var monthToDate = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), samples, Berlin, false, now);
Assert.Equal(BucketStatus.Available, today.Status);
Assert.Equal(BucketStatus.Available, monthToDate.Status);
Assert.Equal(At(2026, 9, 19, 9, 55), today.LastCovered);
}
[Fact]
public void Daily_readings_leave_today_complete_until_the_next_reading_is_due()
{
// Read at 06:00 each day: today's bucket ends at now (10:00), four hours after the last reading.
var now = At(2026, 9, 19, 10);
CoverageRun[] daily = [Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true)];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), daily, Berlin, false, now).Status);
}
[Fact]
public void Coverage_that_stops_more_than_one_interval_before_now_is_partial()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 19, 8, 30), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), hourly, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.PartialCoverage, today.Issue);
}
[Fact]
public void A_known_hole_before_now_is_a_shortfall_not_lag()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] runs =
[
Run(At(2026, 9, 1), At(2026, 9, 19, 9, 30), ResolutionClass.Hour, divided: true),
Gap(At(2026, 9, 19, 9, 30), At(2026, 9, 19, 9, 55), CoverageGapReason.SampleGap),
];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), runs, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.SampleGap, today.Issue);
}
[Fact]
public void The_up_to_date_tolerance_applies_only_to_a_bucket_that_ends_at_now()
{
// The same shortfall in a finished day is a shortfall.
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 18, 23, 55), ResolutionClass.Hour, divided: true)];
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false).Status);
}
[Fact]
public void Monthly_readings_leave_the_month_to_date_complete_until_the_next_one_is_due()
{
// Read on the 15th: September to date is covered to the 15th, and the next reading is not due until October.
var now = At(2026, 9, 19, 10);
CoverageRun[] monthly = [Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), monthly, Berlin, false, now);
Assert.Equal(BucketStatus.Available, september.Status);
}
[Fact]
public void The_running_months_label_row_is_recorded_after_now_so_the_month_to_date_is_missing()
{
// A-04, evaluated from stored runs: the September row closes on 1 October, so nothing covers September
// yet, and a zero would not be a true zero.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [MonthLabels(2026, 1, 2026, 10)];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), stored, Berlin, false, now);
var august = CoverageEvaluator.Evaluate(Month(2026, 8), stored, Berlin, false, now);
Assert.Equal(BucketStatus.Missing, september.Status);
Assert.Equal(BucketStatus.Available, august.Status);
}
[Fact]
public void A_future_stamped_daily_reading_does_not_cost_yesterday_its_coverage()
{
// m2 #5: readings at 06:00 with one already stamped tomorrow. Only the interval containing now is lost.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(Day(2026, 9, 18), stored, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).Status);
Assert.Equal(At(2026, 9, 19, 6), CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).LastCovered);
}
// ---- Another zone ------------------------------------------------------------------------------------
[Fact]
public void In_New_York_monthly_labels_resolve_New_York_months_and_not_their_days()
{
var water = MonthLabels(NewYork, 2026, 1, 2026, 7);
var march = CoverageEvaluator.Evaluate(Month(NewYork, 2026, 3), [water], NewYork, false);
var dstDay = CoverageEvaluator.Evaluate(Day(NewYork, 2026, 3, 8), [water], NewYork, false);
Assert.Equal(BucketStatus.Available, march.Status);
Assert.Equal(TimeSpan.FromDays(31) - TimeSpan.FromHours(1), march.Length);
Assert.Equal(BucketStatus.Unresolved, dstDay.Status);
Assert.Equal(TimeSpan.FromHours(23), dstDay.Length);
}
}
@@ -0,0 +1,343 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Set operations on coverage runs: covered time, capping at now (D-04, D-13), and the joint coverage of a
/// virtual meter's sources (D-27) — where time counts only if every source covers it, as coarsely as the
/// coarsest source.
/// </summary>
public sealed class CoverageRunsTests
{
[Fact]
public void Covered_time_unions_overlapping_runs_ignores_gaps_and_reports_the_outer_bounds()
{
CoverageRun[] runs =
[
Run(At(2026, 3, 10), At(2026, 3, 20), ResolutionClass.Day),
Gap(At(2026, 3, 1), At(2026, 3, 10), CoverageGapReason.SampleGap),
Run(At(2026, 3, 15), At(2026, 3, 25), ResolutionClass.Hour),
Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour),
];
var covered = CoverageRuns.Covered(runs);
Assert.Equal(TimeSpan.FromDays(17), covered.Total);
Assert.Equal(At(2026, 3, 10), covered.First);
Assert.Equal(At(2026, 4, 3), covered.Last);
Assert.False(covered.IsEmpty);
}
[Fact]
public void Covered_time_inside_a_window_is_clipped_to_it()
{
var covered = CoverageRuns.Covered([Run(At(2026, 3, 10), At(2026, 3, 20), ResolutionClass.Day)], At(2026, 3, 15), At(2026, 4, 1));
Assert.Equal(TimeSpan.FromDays(5), covered.Total);
Assert.Equal(At(2026, 3, 15), covered.First);
}
[Fact]
public void Nothing_covered_is_an_empty_span()
{
var covered = CoverageRuns.Covered([Gap(At(2026, 3, 1), At(2026, 3, 10), CoverageGapReason.SampleGap)]);
Assert.True(covered.IsEmpty);
Assert.Equal(CoveredSpan.None, covered);
}
[Fact]
public void A_current_month_label_row_is_recorded_after_now_so_coverage_ends_at_the_month_start()
{
// D-04: the September row describes all of September and is left out of actuals until it closes.
// Clipping at now would leave 1-19 September looking covered by a row nobody reads.
var now = At(2026, 9, 19, 10);
CoverageRun[] runs =
[
MonthLabels(2026, 1, 2026, 10),
Run(At(2026, 10, 1), At(2026, 11, 1), ResolutionClass.Hour),
];
var capped = CoverageRuns.CapAt(runs, now, Berlin);
Assert.Equal([MonthLabels(2026, 1, 2026, 9) with { LastIntervalStart = null }], capped);
}
[Fact]
public void Runs_that_end_by_now_are_kept_unchanged_and_runs_that_start_after_now_are_dropped()
{
var now = At(2026, 9, 19, 10);
var past = Run(At(2026, 1, 1), now, ResolutionClass.Hour, last: At(2026, 9, 19, 9));
Assert.Equal([past], CoverageRuns.CapAt([past, Run(now, At(2026, 10, 1), ResolutionClass.Hour)], now, Berlin));
}
[Fact]
public void A_future_stamped_daily_reading_gives_up_only_the_interval_containing_now()
{
// m2 #5: read at 06:00 daily, the next reading already stamped tomorrow. The run ends where the
// interval containing now starts, not at a midnight or a class limit before it.
var now = At(2026, 9, 19, 10);
var daily = Assert.Single(CoverageRuns.CapAt(
[Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))], now, Berlin));
Assert.Equal(Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true), daily);
}
[Fact]
public void Monthly_readings_with_one_in_the_future_keep_every_share_recorded_before_now()
{
// m2 #5: read on the 15th; the reading of 15 September is stamped in the future (now: 10 September).
// Its interval's August share closed on 1 September and is an actual, so August stays covered.
var now = At(2026, 9, 10, 10);
var monthly = Assert.Single(CoverageRuns.CapAt(
[Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))], now, Berlin));
Assert.Equal(At(2026, 9, 1), monthly.To);
}
[Fact]
public void Weekly_readings_give_up_their_last_week_when_now_falls_inside_it()
{
// m2 #11: burner hours read every Monday at 10:00, the last reading stamped a week ahead.
var stored = Run(At(2026, 8, 3, 10), At(2026, 9, 21, 10), ResolutionClass.Week, divided: true, last: At(2026, 9, 14, 10));
var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 19, 10), Berlin));
Assert.Equal(At(2026, 9, 14, 10), capped.To);
Assert.Null(capped.LastIntervalStart);
}
[Fact]
public void Now_inside_an_earlier_interval_ends_the_run_one_interval_before_now()
{
// A-14: two readings stamped ahead. Only the final interval's start is stored, so the interval containing
// now (14 - 21 September) is not known; it started no earlier than one week-class interval before now, and
// claiming nothing after that never counts time whose row is recorded after now.
var stored = Run(At(2026, 8, 3, 10), At(2026, 9, 28, 10), ResolutionClass.Week, divided: true, last: At(2026, 9, 21, 10));
var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 17, 10), Berlin));
Assert.Equal(At(2026, 9, 17, 10) - ResolutionClassifier.WeekLimit, capped.To);
Assert.Null(capped.LastIntervalStart);
}
[Fact]
public void A_label_run_reaching_past_the_current_month_gives_up_the_current_month()
{
// Review F4: sheet rows July - October, the October row carrying September's register forward. Now (19
// September) falls inside the September row's interval, not the last one; the September row closes after
// now, so September must not look covered — or it would read as a true zero.
var capped = Assert.Single(CoverageRuns.CapAt([MonthLabels(2026, 7, 2026, 11)], At(2026, 9, 19, 10), Berlin));
Assert.Equal(At(2026, 9, 1), capped.To);
}
[Fact]
public void Month_shares_of_a_reading_stamped_weeks_ahead_keep_the_months_before_now()
{
// Review F4: read on 20 August, the next reading typed as 5 October. The August share closed on 1 September
// and is an actual; the September share closes on 30 September, after now.
var stored = Run(At(2026, 7, 20, 9), At(2026, 10, 5, 9), ResolutionClass.Month, divided: true, last: At(2026, 10, 1));
var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 19, 14), Berlin));
Assert.Equal(At(2026, 9, 1), capped.To);
}
[Fact]
public void Hourly_readings_with_two_stamped_ahead_give_up_one_hour_before_now_and_stay_up_to_date()
{
var now = At(2026, 9, 19, 10, 30);
var stored = Run(At(2026, 9, 1), At(2026, 9, 19, 13), ResolutionClass.Hour, divided: true, last: At(2026, 9, 19, 12));
var capped = Assert.Single(CoverageRuns.CapAt([stored], now, Berlin));
Assert.Equal(now - ResolutionClassifier.HourLimit, capped.To);
// A-04: today's bucket still counts as up to date — the coverage reaches within one interval of now.
var today = CutAt(Day(2026, 9, 19), now);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(today, [stored], Berlin, openingBalanceInBucket: false, now).Status);
}
[Fact]
public void A_run_that_does_not_know_its_last_interval_is_cut_at_now()
{
var now = At(2026, 9, 19, 10);
Assert.Equal(now, Assert.Single(CoverageRuns.CapAt([Run(At(2026, 9, 1), At(2026, 9, 20), ResolutionClass.Hour)], now, Berlin)).To);
}
[Fact]
public void An_undivided_interval_longer_than_a_month_straddling_now_is_dropped_whole()
{
var now = At(2026, 9, 19, 10);
Assert.Empty(CoverageRuns.CapAt([Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)], now, Berlin));
}
[Fact]
public void A_divided_interval_straddling_now_keeps_its_shares_of_the_months_before()
{
// Read on 20 July and (stamped ahead) on 25 September: the September share is the run's last
// interval and closes after now; the July and August shares closed before it and are actuals.
var now = At(2026, 9, 19, 10);
var capped = CoverageRuns.CapAt([Run(At(2026, 7, 20), At(2026, 9, 25), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))], now, Berlin);
Assert.Equal([Run(At(2026, 7, 20), At(2026, 9, 1), ResolutionClass.Month, divided: true)], capped);
}
[Fact]
public void A_gap_straddling_now_is_clipped_at_now()
{
var now = At(2026, 9, 19, 10);
var capped = CoverageRuns.CapAt([Gap(At(2026, 9, 18), At(2026, 9, 21), CoverageGapReason.SampleGap) with { LastIntervalStart = At(2026, 9, 18) }], now, Berlin);
Assert.Equal(now, Assert.Single(capped).To);
}
[Fact]
public void Zero_length_runs_claim_no_time_and_are_dropped()
{
// A-01: an opening balance is not a run; nothing zero-length survives capping.
var now = At(2026, 9, 19, 10);
Assert.Empty(CoverageRuns.CapAt([Run(now.AddHours(-1), now.AddHours(-1), ResolutionClass.Hour)], now, Berlin));
}
[Fact]
public void Capping_twice_at_the_same_instant_changes_nothing()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] stored =
[
MonthLabels(2026, 1, 2026, 10),
Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6)),
Run(At(2026, 9, 1), At(2026, 9, 20), ResolutionClass.Hour),
];
var once = CoverageRuns.CapAt(stored, now, Berlin);
Assert.Equal(once, CoverageRuns.CapAt(once, now, Berlin));
}
[Fact]
public void In_New_York_the_running_months_label_is_given_up_at_the_New_York_month_start()
{
var now = At(NewYork, 2026, 9, 19, 10);
var capped = Assert.Single(CoverageRuns.CapAt([MonthLabels(NewYork, 2026, 1, 2026, 10)], now, NewYork));
Assert.Equal(At(NewYork, 2026, 9, 1), capped.To);
Assert.Equal(new DateTimeOffset(2026, 9, 1, 4, 0, 0, TimeSpan.Zero), capped.To);
}
[Fact]
public void The_joint_coverage_of_a_monthly_and_an_hourly_source_is_their_overlap_at_month_resolution()
{
var joint = CoverageRuns.Intersect(
[
[MonthLabels(2026, 1, 2026, 7)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
]);
var run = Assert.Single(joint);
Assert.Equal(At(2026, 3, 15, 12), run.From);
Assert.Equal(At(2026, 7, 1), run.To);
Assert.Equal(ResolutionClass.Month, run.Resolution);
// Only divided when every source was: the hourly samples were never divided.
Assert.False(run.DividedAtMonths);
}
[Fact]
public void Joint_runs_split_where_the_coarsest_class_changes_and_merge_where_it_does_not()
{
var joint = CoverageRuns.Intersect(
[
[
Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour),
Run(At(2026, 2, 1), At(2026, 3, 1), ResolutionClass.Hour),
Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Week),
],
[Run(At(2026, 1, 1), At(2026, 4, 1), ResolutionClass.Day)],
]);
Assert.Equal(
[
Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Day),
Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Week),
],
joint);
}
[Fact]
public void Joint_coverage_is_divided_only_where_every_source_is()
{
var joint = CoverageRuns.Intersect(
[
[MonthLabels(2025, 1, 2026, 1)],
[MonthLabels(2025, 1, 2025, 7), Run(At(2025, 7, 1), At(2026, 1, 1), ResolutionClass.Month)],
]);
// Joint runs do not know where any source's last interval starts.
Assert.Equal(
[
MonthLabels(2025, 1, 2025, 7) with { LastIntervalStart = null },
Run(At(2025, 7, 1), At(2026, 1, 1), ResolutionClass.Month),
],
joint);
}
[Fact]
public void A_hole_in_one_source_is_a_hole_in_the_joint_coverage_and_keeps_its_reason()
{
var outage = Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap);
var joint = CoverageRuns.Intersect(
[
[Run(At(2026, 3, 1), At(2026, 3, 10, 14), ResolutionClass.Hour), outage, Run(At(2026, 3, 13, 9), At(2026, 4, 1), ResolutionClass.Hour)],
[Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Day)],
]);
Assert.Equal(
[
Run(At(2026, 3, 1), At(2026, 3, 10, 14), ResolutionClass.Day),
outage,
Run(At(2026, 3, 13, 9), At(2026, 4, 1), ResolutionClass.Day),
],
joint);
}
[Fact]
public void A_source_without_coverage_leaves_no_joint_coverage()
{
var joint = CoverageRuns.Intersect(
[
[Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Hour)],
[],
]);
Assert.Empty(joint);
Assert.Empty(CoverageRuns.Intersect([]));
}
[Fact]
public void A_single_source_intersects_to_its_own_coverage_with_touching_runs_merged()
{
var joint = CoverageRuns.Intersect(
[
[
Run(At(2026, 3, 1), At(2026, 3, 15), ResolutionClass.Hour),
Run(At(2026, 3, 15), At(2026, 4, 1), ResolutionClass.Hour),
],
]);
Assert.Equal([Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Hour)], joint);
}
}
@@ -0,0 +1,134 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Normalization;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Terse builders for coverage tests: local instants, local buckets and coverage runs (Berlin unless a zone is given).</summary>
internal static class CoverageTestData
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
/// <summary>A Berlin wall-clock time as an instant (UTC).</summary>
public static DateTimeOffset At(int year, int month, int day, int hour = 0, int minute = 0) => At(Berlin, year, month, day, hour, minute);
/// <summary>A wall-clock time in <paramref name="zone"/> as an instant (UTC).</summary>
public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0)
{
var wall = new DateTime(year, month, day, hour, minute, 0);
return new DateTimeOffset(wall, zone.GetUtcOffset(wall)).ToUniversalTime();
}
public static DateTimeOffset Midnight(DateOnly date) => GapAttribution.LocalMidnight(date, Berlin);
public static AnalysisBucket Day(int year, int month, int day) => Day(Berlin, year, month, day);
public static AnalysisBucket Day(TimeZoneInfo zone, int year, int month, int day)
{
var date = new DateOnly(year, month, day);
return Bucket(zone, date, date.AddDays(1), BucketSize.Day);
}
/// <summary>The Monday-start week containing the given date.</summary>
public static AnalysisBucket Week(int year, int month, int day)
{
var date = new DateOnly(year, month, day);
var monday = date.AddDays(-(((int)date.DayOfWeek + 6) % 7));
return Bucket(Berlin, monday, monday.AddDays(7), BucketSize.Week);
}
public static AnalysisBucket Month(int year, int month) => Month(Berlin, year, month);
public static AnalysisBucket Month(TimeZoneInfo zone, int year, int month)
{
var first = new DateOnly(year, month, 1);
return Bucket(zone, first, first.AddMonths(1), BucketSize.Month);
}
public static AnalysisBucket Year(int year)
{
var first = new DateOnly(year, 1, 1);
return Bucket(Berlin, first, first.AddYears(1), BucketSize.Year);
}
/// <summary>A bucket cut at <paramref name="now"/>: the to-date bucket of a period that ends now.</summary>
public static AnalysisBucket CutAt(AnalysisBucket bucket, DateTimeOffset now) =>
bucket with { To = now, EndDay = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime).AddDays(1), NominalEndDay = bucket.EndDay };
public static CoverageRun Run(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false, DateTimeOffset? last = null) =>
new(from, to, resolution, divided, CoverageGapReason.None, last);
/// <summary>A run that is one source interval: it knows its final interval starts where it does.</summary>
public static CoverageRun Single(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) =>
Run(from, to, resolution, divided, last: from);
public static CoverageRun Gap(DateTimeOffset from, DateTimeOffset to, CoverageGapReason reason) =>
new(from, to, ResolutionClass.Hour, false, reason);
/// <summary>
/// A run of imported monthly table rows ("Jan 2022" … ): each row's interval is its labelled local month,
/// so the run is month-class, month-aligned, and its last interval is its last month.
/// </summary>
public static CoverageRun MonthLabels(int fromYear, int fromMonth, int toYear, int toMonthExclusive) =>
MonthLabels(Berlin, fromYear, fromMonth, toYear, toMonthExclusive);
public static CoverageRun MonthLabels(TimeZoneInfo zone, int fromYear, int fromMonth, int toYear, int toMonthExclusive)
{
var end = new DateOnly(toYear, toMonthExclusive, 1);
return Run(
GapAttribution.LocalMidnight(new DateOnly(fromYear, fromMonth, 1), zone),
GapAttribution.LocalMidnight(end, zone),
ResolutionClass.Month,
divided: true,
last: GapAttribution.LocalMidnight(end.AddMonths(-1), zone));
}
/// <summary>
/// The calendar shift of a comparison (D-06), as the period resolver maps a cut-off: the same local wall
/// time on the shifted date. A date the target month lacks (29 February, 31 April) cuts at that month's
/// end; a wall time lost to DST takes the first instant after the gap; a repeated one its first occurrence.
/// </summary>
public static DateTimeOffset ShiftYears(DateTimeOffset instant, int years) => Shift(instant, Berlin, months: 12 * years);
public static DateTimeOffset ShiftMonths(DateTimeOffset instant, int months) => Shift(instant, Berlin, months: months);
public static DateTimeOffset ShiftDays(DateTimeOffset instant, int days) => Shift(instant, Berlin, days: days);
public static DateTimeOffset Shift(DateTimeOffset instant, TimeZoneInfo zone, int months = 0, int days = 0)
{
var wall = TimeZoneInfo.ConvertTime(instant, zone).DateTime;
if (days != 0)
{
return FromWall(wall.AddDays(days), zone);
}
var target = wall.AddMonths(months);
return target.Day != wall.Day
? GapAttribution.LocalMidnight(new DateOnly(target.Year, target.Month, 1).AddMonths(1), zone)
: FromWall(target, zone);
}
public static ResolvedPeriod Period(PeriodPreset preset, DateOnly firstDay, DateOnly lastDay, DateTimeOffset now)
{
var from = Midnight(firstDay);
var endOfLastDay = Midnight(lastDay.AddDays(1));
var toDate = now < endOfLastDay;
return new ResolvedPeriod(preset, firstDay, lastDay, from, toDate ? now : endOfLastDay, now, toDate, false, Berlin);
}
private static AnalysisBucket Bucket(TimeZoneInfo zone, DateOnly first, DateOnly end, BucketSize size) =>
new(first, end, GapAttribution.LocalMidnight(first, zone), GapAttribution.LocalMidnight(end, zone), size);
private static DateTimeOffset FromWall(DateTime wall, TimeZoneInfo zone)
{
var local = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
while (zone.IsInvalidTime(local))
{
local = local.AddMinutes(1);
}
var offset = zone.IsAmbiguousTime(local) ? zone.GetAmbiguousTimeOffsets(local).Max() : zone.GetUtcOffset(local);
return new DateTimeOffset(local, offset).ToUniversalTime();
}
}
@@ -0,0 +1,108 @@
using MeterVault.Core.Analysis.Virtual;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Nested virtual meters are evaluated dependencies first, a loop is reported with the path through it and
/// withheld from evaluation, and a request expands to the physical meters it finally reads (D-27).
/// </summary>
public sealed class DependencyGraphTests
{
private static DependencyGraph Graph(params (int Meter, int[] Dependencies)[] meters) =>
DependencyGraph.Build(meters.ToDictionary(m => m.Meter, m => (IReadOnlyList<int>)m.Dependencies));
[Fact]
public void Nested_virtual_meters_are_ordered_dependencies_first_and_expand_to_their_physical_leaves()
{
// 10 = m1 + m2, 11 = m10 - m3, 12 = m11 + m10
var graph = Graph((12, [11, 10]), (11, [10, 3]), (10, [1, 2]));
Assert.Equal([10, 11, 12], graph.EvaluationOrder);
Assert.Equal([1, 2, 3], graph.PhysicalLeaves(12));
Assert.Equal([1, 2], graph.PhysicalLeaves(10));
Assert.Equal([7], graph.PhysicalLeaves(7));
Assert.Equal([12, 10, 1], graph.PathTo(12, 1)); // the shortest way, not 12 → 11 → 10 → 1
Assert.Null(graph.PathTo(10, 3));
Assert.Empty(graph.Cycles);
}
[Fact]
public void Evaluation_order_for_a_request_holds_only_what_it_reaches()
{
var graph = Graph((12, [11]), (11, [10]), (10, [1]), (20, [2]));
Assert.Equal([10, 11], graph.EvaluationOrderFor([11]));
Assert.Equal([20], graph.EvaluationOrderFor([20, 1]));
}
[Fact]
public void A_loop_is_reported_with_its_path_and_blocks_everything_that_depends_on_it()
{
// 10 → 11 → 12 → 10 is a loop; 13 reads it; 14 is independent.
var graph = Graph((10, [11]), (11, [12, 1]), (12, [10]), (13, [10, 2]), (14, [1]));
var cycle = Assert.Single(graph.Cycles);
Assert.Equal([10, 11, 12, 10], cycle);
Assert.Equal([11, 12, 10, 11], graph.CycleFor(11));
Assert.Equal([13, 10, 11, 12, 10], graph.CycleFor(13));
Assert.False(graph.IsEvaluable(13));
Assert.True(graph.IsEvaluable(14));
Assert.True(graph.IsEvaluable(1));
Assert.Null(graph.CycleFor(14));
Assert.Equal([14], graph.EvaluationOrder);
}
[Fact]
public void A_meter_referring_to_itself_is_a_loop_of_one()
{
var graph = Graph((10, [10, 1]));
Assert.Equal([10, 10], Assert.Single(graph.Cycles));
Assert.Empty(graph.EvaluationOrder);
Assert.Equal([1], graph.PhysicalLeaves(10));
}
[Fact]
public void Dependents_are_the_virtual_meters_a_deletion_would_break()
{
var graph = Graph((10, [1, 2]), (11, [10, 3]), (12, [3]));
Assert.Equal([10, 11], graph.Dependents(1));
Assert.Equal([11, 12], graph.Dependents(3));
Assert.Equal([11], graph.Dependents(10));
Assert.Empty(graph.Dependents(11));
}
[Fact]
public void A_legacy_virtual_meter_without_a_calculation_is_not_mistaken_for_a_physical_leaf()
{
var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity()); // Summe Solar (6) has no definition
var graph = DependencyGraph.FromCatalog(catalog);
Assert.True(graph.IsVirtual(6));
Assert.Empty(graph.PhysicalLeaves(6));
Assert.False(graph.IsVirtual(4));
}
[Fact]
public void A_chain_of_five_thousand_virtual_meters_is_walked_without_recursion()
{
var chain = Enumerable.Range(1, 5000).Select(i => (Meter: 10_000 + i, Dependencies: new[] { i == 1 ? 1 : 10_000 + i - 1 })).ToArray();
var graph = Graph(chain);
Assert.Equal(5000, graph.EvaluationOrder.Count);
Assert.Equal(10_001, graph.EvaluationOrder[0]);
Assert.Equal([1], graph.PhysicalLeaves(15_000));
Assert.Equal(5001, graph.PathTo(15_000, 1)!.Count);
var looped = Graph([.. chain.Skip(1), (10_001, [15_000])]);
Assert.Equal(5001, Assert.Single(looped.Cycles).Count);
}
[Fact]
public void Paths_format_as_meter_ids_joined_by_the_separator()
{
Assert.Equal("12>7>12", DependencyGraph.FormatPath([12, 7, 12]));
}
}
@@ -0,0 +1,761 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Core.Normalization.Normalizers;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Every normalized row says which stretch of time it accrued over (D-10), and a row that closes at a
/// local midnight is booked in the day it closes (D-11). Coverage, rollups and bucket status are built
/// on these intervals, so each mode is pinned here — including the first reading, whose start is only
/// known from a month label or an install date.
/// </summary>
public sealed class EngineIntervalTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Reading Manual(DateTimeOffset time, double value, int meterId = 1) =>
new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Manual };
private static Reading Measured(DateTimeOffset time, double value, int meterId = 1) =>
new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Measured };
private static NormalizationContext Context(
MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, IReadOnlyList<MeterEvent>? events = null, DateOnly? installedAt = null) =>
new()
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt },
Readings = readings,
Events = events ?? [],
TimeZone = zone,
};
private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id);
// ---- Registers -------------------------------------------------------------------------------
[Fact]
public void Each_register_row_covers_the_time_since_the_previous_reading()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[
Manual(InBerlin(2026, 9, 2, 8), 100),
Manual(InBerlin(2026, 9, 10, 20), 110),
Manual(InBerlin(2026, 9, 20, 6), 125),
]));
Assert.Equal(3, result.Count);
Assert.Equal([InBerlin(2026, 9, 2, 8), InBerlin(2026, 9, 10, 20)], result.Skip(1).Select(c => c.IntervalStart!.Value));
Assert.Equal([InBerlin(2026, 9, 10, 20), InBerlin(2026, 9, 20, 6)], result.Skip(1).Select(c => c.IntervalEnd!.Value));
Assert.All(result.Skip(1), c =>
{
Assert.False(c.Divided);
Assert.False(c.OpeningBalance);
Assert.Equal(CoverageGapReason.None, c.Gap);
});
}
[Fact]
public void A_first_reading_without_a_label_or_install_date_is_an_opening_balance_with_an_unknown_start()
{
var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)])));
Assert.True(first.OpeningBalance);
Assert.Equal(300, first.Amount);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalStart);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time);
}
[Fact]
public void A_first_reading_starts_at_the_local_midnight_of_the_install_date()
{
var first = Assert.Single(_engine.Normalize(Context(
MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 8, 15))));
Assert.False(first.OpeningBalance);
Assert.False(first.Divided); // the baseline delta is booked at its reading, as it always was
Assert.Equal(Utc(2026, 8, 14, 22), first.IntervalStart); // 15 August 00:00 in Berlin
Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time);
}
[Fact]
public void An_install_date_after_the_first_reading_says_nothing_about_its_start()
{
var first = Assert.Single(_engine.Normalize(Context(
MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 9, 5))));
Assert.True(first.OpeningBalance);
Assert.Equal(first.IntervalEnd, first.IntervalStart);
}
[Fact]
public void A_first_month_row_covers_the_local_month_it_names()
{
// Zähler Auto's first sheet row, "Mai 2023" = 3755: the whole register booked as May's figure.
var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Reading(1, Month(2023, 5), 3755)])));
Assert.False(first.OpeningBalance);
Assert.Equal(InBerlin(2023, 5, 1), first.IntervalStart);
Assert.Equal(InBerlin(2023, 6, 1), first.IntervalEnd);
Assert.Equal(Month(2023, 5), first.Time);
}
[Theory]
[InlineData("UTC")]
[InlineData("Europe/Berlin")]
public void Consecutive_month_rows_keep_their_stamps_and_span_exactly_their_months(string zoneId)
{
// The golden-fixture shape. A label closes at a local midnight (its month's end) but is a month
// figure, not a reading at that instant: it keeps its stamp.
var zone = Zone(zoneId);
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone,
[
Reading(1, Month(2022, 9), 0),
Reading(1, Month(2022, 10), 411),
Reading(1, Month(2022, 11), 1153),
Reading(1, Month(2022, 12), 1968),
]));
Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time));
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
DateTimeOffset MonthStart(int month) => GapAttribution.LocalMidnight(new DateOnly(2022, month, 1), zone);
Assert.Equal([MonthStart(9), MonthStart(10), MonthStart(11), MonthStart(12)], result.Select(c => c.IntervalStart!.Value));
Assert.Equal([MonthStart(10), MonthStart(11), MonthStart(12), GapAttribution.LocalMidnight(new DateOnly(2023, 1, 1), zone)],
result.Select(c => c.IntervalEnd!.Value));
Assert.DoesNotContain(result, c => c.Divided || c.OpeningBalance || c.Gap != CoverageGapReason.None);
}
[Fact]
public void An_interval_divided_at_a_month_boundary_gives_each_share_its_own_segment()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[
Manual(InBerlin(2026, 8, 1, 9), 700),
Manual(InBerlin(2026, 9, 16, 18), 746),
]));
var shares = result.Skip(1).ToList();
Assert.Equal(2, shares.Count);
Assert.All(shares, c =>
{
Assert.True(c.Divided);
Assert.Equal(ReadingQuality.Estimated, c.Quality);
});
Assert.Equal(InBerlin(2026, 8, 1, 9), shares[0].IntervalStart);
Assert.Equal(InBerlin(2026, 9, 1), shares[0].IntervalEnd);
Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), shares[0].Time);
Assert.Equal(InBerlin(2026, 9, 1), shares[1].IntervalStart);
Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].IntervalEnd);
Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].Time);
Assert.Equal(46, shares.Sum(c => c.Amount), 9);
}
[Fact]
public void Attribution_segments_tile_the_interval_at_local_month_starts()
{
var from = InBerlin(2026, 5, 20, 7);
var to = InBerlin(2026, 8, 3, 21);
var segments = GapAttribution.Attribute(from, to, to, 1000, Berlin);
Assert.Equal([from, InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1)], segments.Select(s => s.From));
Assert.Equal([InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1), to], segments.Select(s => s.To));
Assert.All(segments, s => Assert.True(s.Time >= s.From && s.Time <= s.To, $"{s.Time:O} lies outside its segment"));
}
[Fact]
public void An_unexplained_decrease_marks_its_interval_as_a_gap()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, TimeZoneInfo.Utc,
[Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 60), Reading(1, Month(2023, 3), 70)]));
Assert.Equal([CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None], result.Select(c => c.Gap));
Assert.Equal(Month(2023, 2), result[1].IntervalStart);
Assert.Equal(Month(2023, 3), result[1].IntervalEnd);
Assert.Equal(0, result[1].Amount);
}
[Theory]
[InlineData(null, CoverageGapReason.ResetWithoutPrevious)]
[InlineData(170d, CoverageGapReason.None)]
public void A_reset_is_a_gap_only_when_it_does_not_say_where_the_old_register_stopped(double? prevValue, CoverageGapReason expected)
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InitialBaseline = 90 },
Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 150), Reading(1, Month(2023, 3), 30)],
Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: prevValue)],
});
Assert.Equal(expected, result[^1].Gap);
Assert.Equal(Month(2023, 3), result[^1].IntervalStart);
}
[Fact]
public void A_swap_with_its_amount_is_covered_time()
{
// The water register …861 → 2 books 12 m³ across the swap month: no hole.
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings = [Reading(1, Month(2023, 1), 848), Reading(1, Month(2023, 2), 861), Reading(1, Month(2023, 3), 2), Reading(1, Month(2023, 4), 15)],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
});
Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None);
Assert.Equal(Month(2023, 3), result[2].IntervalStart);
Assert.Equal(Month(2023, 4), result[2].IntervalEnd);
}
[Fact]
public void Every_row_names_the_whole_reading_interval_it_came_from()
{
// A-03: shares are classified and told apart by their source interval, not by their own segment.
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[
Manual(InBerlin(2026, 8, 2, 9), 700),
Manual(InBerlin(2026, 8, 10, 9), 720),
Manual(InBerlin(2026, 9, 16, 18), 766),
]));
var inside = result.Single(c => c.IntervalEnd == InBerlin(2026, 8, 10, 9));
var shares = result.Where(c => c.Divided).ToList();
Assert.Equal((inside.IntervalStart, inside.IntervalEnd), (inside.SourceStart, inside.SourceEnd));
Assert.Equal(2, shares.Count);
Assert.All(shares, c =>
{
Assert.Equal(InBerlin(2026, 8, 10, 9), c.SourceStart);
Assert.Equal(InBerlin(2026, 9, 16, 18), c.SourceEnd);
});
Assert.NotEqual(shares[0].IntervalEnd, shares[1].IntervalEnd);
}
[Fact]
public void A_register_that_stands_still_across_a_month_boundary_is_one_row_exact_in_every_month()
{
// m7 #2, A-02: nothing moved from 20 June to 20 July. One row, zero, flagged as month-exact, and not
// estimated — nothing about it was inferred.
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 130)]));
var still = result[^1];
Assert.Equal(2, result.Count);
Assert.Equal(0, still.Amount);
Assert.True(still.Divided);
Assert.Equal(ReadingQuality.Manual, still.Quality);
Assert.Equal(InBerlin(2026, 6, 20, 12), still.IntervalStart);
Assert.Equal(InBerlin(2026, 7, 20, 12), still.IntervalEnd);
}
[Fact]
public void A_standstill_inside_one_month_or_a_rejected_decrease_is_not_flagged()
{
var inside = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 6, 2, 12), 130), Manual(InBerlin(2026, 6, 20, 12), 130)]));
var decrease = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 90)]));
Assert.False(inside[^1].Divided);
Assert.False(decrease[^1].Divided);
Assert.Equal(CoverageGapReason.UnexplainedDecrease, decrease[^1].Gap);
}
[Theory]
[InlineData(MeterMode.RuntimeCounter)]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.GenerationCounter)]
public void Zero_across_a_month_boundary_is_exact_in_every_month_for_every_register_like_mode(MeterMode mode)
{
var first = mode == MeterMode.DirectDelta ? 4 : 130;
var second = mode == MeterMode.DirectDelta ? 0 : 130;
var result = _engine.Normalize(Context(mode, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), first), Manual(InBerlin(2026, 7, 20, 12), second)]));
Assert.Equal(0, result[^1].Amount);
Assert.True(result[^1].Divided);
Assert.Equal(ReadingQuality.Manual, result[^1].Quality);
}
[Fact]
public void Burner_hours_that_moved_across_a_month_boundary_stay_undivided()
{
var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 150)]));
Assert.False(result[^1].Divided);
Assert.Equal(20, result[^1].Amount);
}
// ---- Midnight stamps (D-11) ------------------------------------------------------------------
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
[InlineData("UTC")]
public void A_daily_snapshot_at_local_midnight_is_booked_in_the_day_it_closes(string zoneId)
{
var zone = Zone(zoneId);
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone,
[
Measured(Local(zone, 2026, 9, 10), 100),
Measured(Local(zone, 2026, 9, 11), 105),
Measured(Local(zone, 2026, 9, 12), 112),
]));
// The opening balance describes no time before its midnight, so it stays on it.
Assert.Equal(Local(zone, 2026, 9, 10), result[0].Time);
Assert.Equal([Local(zone, 2026, 9, 10, 23, 59, 59), Local(zone, 2026, 9, 11, 23, 59, 59)], result.Skip(1).Select(c => c.Time));
Assert.Equal([new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 11)], result.Skip(1).Select(c => LocalDate(c.Time, zone)));
Assert.Equal([5d, 7d], result.Skip(1).Select(c => c.Amount));
Assert.Equal([Local(zone, 2026, 9, 11), Local(zone, 2026, 9, 12)], result.Skip(1).Select(c => c.IntervalEnd!.Value));
Assert.All(result.Skip(1), c => Assert.Equal(ReadingQuality.Measured, c.Quality));
}
[Fact]
public void A_daily_snapshot_after_the_long_autumn_day_is_stamped_inside_that_day()
{
// 25 October 2026 has 25 hours in Berlin.
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Measured(InBerlin(2026, 10, 25), 100), Measured(InBerlin(2026, 10, 26), 110)]));
Assert.Equal(new DateOnly(2026, 10, 25), LocalDate(result[1].Time, Berlin));
Assert.Equal(TimeSpan.FromHours(25), result[1].IntervalEnd - result[1].IntervalStart);
}
[Theory]
[InlineData(MeterMode.CumulativeCounter)]
[InlineData(MeterMode.RuntimeCounter)]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.InstantRate)]
public void The_midnight_that_ends_a_month_books_its_row_on_that_months_last_day(MeterMode mode)
{
var result = _engine.Normalize(Context(mode, Berlin,
[Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 1), 130)]));
var closing = result[^1];
Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), closing.Time);
Assert.Equal(InBerlin(2026, 8, 20, 12), closing.IntervalStart);
Assert.Equal(InBerlin(2026, 9, 1), closing.IntervalEnd);
}
[Fact]
public void A_divided_interval_that_closes_at_a_midnight_stamps_its_last_share_in_the_day_before()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 10), 142)]));
var shares = result.Skip(1).ToList();
Assert.Equal([InBerlin(2026, 8, 31, 23, 59, 59), InBerlin(2026, 9, 9, 23, 59, 59)], shares.Select(c => c.Time));
Assert.Equal([InBerlin(2026, 9, 1), InBerlin(2026, 9, 10)], shares.Select(c => c.IntervalEnd!.Value));
Assert.All(shares, c => Assert.True(c.Divided));
}
[Fact]
public void A_day_dated_import_at_utc_midnight_closes_the_day_before_only_where_that_is_local_midnight()
{
// The importer stamps "20.07.2026" at 00:00 UTC. In a UTC instance that is the midnight that ends
// 19 July; in Berlin it is 02:00 on the 20th, an instant inside the day.
Reading[] readings = [DayReading(1, Utc(2026, 7, 15), 600), DayReading(1, Utc(2026, 7, 20), 650)];
var inUtc = _engine.Normalize(Context(MeterMode.RuntimeCounter, TimeZoneInfo.Utc, readings));
var inBerlin = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, readings));
Assert.Equal(Utc(2026, 7, 20).AddSeconds(-1), inUtc[^1].Time);
Assert.Equal(Utc(2026, 7, 20), inBerlin[^1].Time);
Assert.Equal(Utc(2026, 7, 20), inUtc[^1].IntervalEnd);
}
[Fact]
public void Where_midnight_happens_twice_only_the_first_starts_the_day()
{
// Havana leaves daylight time at 01:00 on 1 November 2026: 00:00 is at 04:00 and again at 05:00 UTC.
var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana");
Assert.True(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 4), havana));
Assert.False(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 5), havana));
Assert.True(GapAttribution.IsLocalMonthStart(Utc(2026, 11, 1, 4), havana));
}
[Fact]
public void A_midnight_stamp_never_leaves_an_interval_shorter_than_a_second()
{
var end = InBerlin(2026, 9, 1);
var start = end.AddMilliseconds(-400);
var stamp = GapAttribution.CloseStamp(start, end, Berlin);
Assert.True(stamp > start && stamp < end, $"{stamp:O} lies outside the interval");
Assert.Equal(end, GapAttribution.CloseStamp(end, end, Berlin));
Assert.Equal(InBerlin(2026, 9, 1, 0, 0, 1), GapAttribution.CloseStamp(start, InBerlin(2026, 9, 1, 0, 0, 1), Berlin));
}
// ---- Runtime ---------------------------------------------------------------------------------
[Fact]
public void Burner_rows_run_from_the_previous_reading_and_a_long_silence_stays_one_interval()
{
// The seeded burner: 0 h on 18.10.2010, then nothing until 7758 h on 14.10.2022.
var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin,
[
DayReading(1, Utc(2010, 10, 18), 0),
DayReading(1, Utc(2022, 10, 14), 7758),
DayReading(1, Utc(2022, 11, 28), 7758),
]));
Assert.Equal(3, result.Count);
Assert.True(result[0].OpeningBalance);
Assert.Equal(Utc(2010, 10, 18), result[1].IntervalStart);
Assert.Equal(Utc(2022, 10, 14), result[1].IntervalEnd);
Assert.Equal(7758, result[1].Amount);
Assert.Equal(Utc(2022, 10, 14), result[1].Time);
Assert.Equal(Utc(2022, 10, 14), result[2].IntervalStart);
// The twelve years of hours are not divided; the burner that then stood still across 1 November is
// exactly zero in October and November alike (A-02).
Assert.False(result[1].Divided);
Assert.True(result[2].Divided);
Assert.Equal(0, result[2].Amount);
}
[Fact]
public void Burner_hours_that_go_backwards_or_reset_blind_are_gaps()
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" },
Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 40), Reading(1, Month(2023, 3), 50), Reading(1, Month(2023, 4), 10)],
Events = [Reset(1, Month(2023, 4), newValue: 0)],
});
Assert.Equal(
[CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None, CoverageGapReason.ResetWithoutPrevious],
result.Select(c => c.Gap));
Assert.Equal([100d, 0d, 10d, 10d], result.Select(c => c.Amount));
}
[Fact]
public void A_first_burner_reading_covers_its_label_month_or_starts_at_the_install_date()
{
var label = Assert.Single(_engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, [Reading(1, Month(2023, 1), 7952)])));
var installed = Assert.Single(_engine.Normalize(Context(
MeterMode.RuntimeCounter, Berlin, [Manual(InBerlin(2023, 1, 20, 10), 12)], installedAt: new DateOnly(2023, 1, 2))));
Assert.Equal(InBerlin(2023, 1, 1), label.IntervalStart);
Assert.Equal(InBerlin(2023, 2, 1), label.IntervalEnd);
Assert.False(label.OpeningBalance);
Assert.Equal(InBerlin(2023, 1, 2), installed.IntervalStart);
Assert.False(installed.OpeningBalance);
}
// ---- Tank ------------------------------------------------------------------------------------
[Fact]
public void A_tank_row_covers_the_time_between_two_dipsticks_and_early_deliveries_cover_nothing()
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig
{
MeterId = 30,
Mode = MeterMode.ConsumableBalance,
Unit = "L",
Tank = new TankConfig { Capacity = 7000, Calibration = new CalibrationCurve(7000d / 150d) },
},
Events =
[
Delivery(30, Utc(2020, 9, 7), 3500),
TankLevelCm(30, Utc(2022, 9, 8), 35),
TankLevelCm(30, Utc(2022, 10, 14), 34),
Delivery(30, Utc(2022, 12, 5), 3000),
TankLevelCm(30, Utc(2022, 12, 5), 85),
],
TimeZone = Berlin,
});
Assert.Equal(2, result.Count);
Assert.Equal([Utc(2022, 9, 8), Utc(2022, 10, 14)], result.Select(c => c.IntervalStart!.Value));
Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.IntervalEnd!.Value));
Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.Time));
Assert.DoesNotContain(result, c => c.OpeningBalance || c.Divided || c.Gap != CoverageGapReason.None);
}
[Fact]
public void A_dipstick_at_local_midnight_is_booked_in_the_day_it_closes()
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" },
Events =
[
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 15, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" },
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 9, 1), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" },
],
TimeZone = Berlin,
});
var row = Assert.Single(result);
Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), row.Time);
Assert.Equal(InBerlin(2026, 9, 1), row.IntervalEnd);
Assert.Equal(200, row.Amount);
}
[Fact]
public void A_tank_nobody_drew_from_across_a_month_boundary_is_exact_in_every_month_but_a_level_that_rose_is_not()
{
MeterEvent Level(DateTimeOffset time, double litres) =>
new() { MeterId = 30, Time = time, EventType = MeterEventType.TankLevel, Amount = litres, Unit = "L" };
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" },
Events = [Level(InBerlin(2026, 6, 20, 10), 3000), Level(InBerlin(2026, 7, 20, 10), 3000), Level(InBerlin(2026, 8, 20, 10), 3100)],
TimeZone = Berlin,
});
Assert.Equal([true, false], result.Select(c => c.Divided));
Assert.Equal([ReadingQuality.Manual, ReadingQuality.Estimated], result.Select(c => c.Quality));
Assert.All(result, c => Assert.Equal(0, c.Amount));
}
// ---- Instant rate ----------------------------------------------------------------------------
[Fact]
public void A_silence_far_longer_than_the_sensors_rhythm_is_a_sample_gap_that_keeps_its_integral()
{
// 2 kW sampled every five minutes from 10:00 to 11:00, then nothing until 14:00, then 14:05.
var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2))
.Append(Measured(Utc(2024, 6, 1, 14), 2))
.Append(Measured(Utc(2024, 6, 1, 14, 5), 2))
.ToList();
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples));
var gap = Assert.Single(result, c => c.Gap != CoverageGapReason.None);
Assert.Equal(CoverageGapReason.SampleGap, gap.Gap);
Assert.Equal(ReadingQuality.Estimated, gap.Quality);
Assert.Equal(6, gap.Amount, 9); // 2 kW over three hours, still counted
Assert.Equal(Utc(2024, 6, 1, 11), gap.IntervalStart);
Assert.Equal(Utc(2024, 6, 1, 14), gap.IntervalEnd);
Assert.Equal(13, result.Count(c => c.Gap == CoverageGapReason.None && c.Quality == ReadingQuality.Measured));
Assert.Equal(CoverageGapReason.None, result.Single(c => c.IntervalStart == Utc(2024, 6, 1, 14)).Gap);
}
[Fact]
public void Each_sample_interval_is_a_row_interval_and_the_first_sample_only_seeds_the_integral()
{
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc,
[Measured(Utc(2024, 6, 1, 10), 0), Measured(Utc(2024, 6, 1, 11), 2), Measured(Utc(2024, 6, 1, 12), 4)]));
Assert.Equal([Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11)], result.Select(c => c.IntervalStart!.Value));
Assert.Equal([Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 12)], result.Select(c => c.IntervalEnd!.Value));
Assert.DoesNotContain(result, c => c.OpeningBalance || c.Gap != CoverageGapReason.None);
}
[Theory]
[InlineData(new[] { 1, 1, 1, 1 }, 61)] // a fast sensor: ten minutes would be too eager, an hourly interval is the floor
[InlineData(new[] { 60, 60, 60 }, 600)] // hourly: a gap is longer than ten hours
[InlineData(new[] { 5, 5, 5, 180 }, 61)] // the gap itself does not raise the threshold: the median stays 5
[InlineData(new[] { 10, 10, 20, 20 }, 150)] // an even count takes the middle of the two middle steps
public void The_sample_gap_threshold_is_ten_median_intervals_but_never_under_an_hourly_interval(int[] stepMinutes, int expectedMinutes)
{
var time = Utc(2024, 6, 1);
var samples = new List<Reading> { Measured(time, 1) };
foreach (var step in stepMinutes)
{
time = time.AddMinutes(step);
samples.Add(Measured(time, 1));
}
var thresholds = InstantRateNormalizer.SampleGapThresholds(samples);
Assert.Equal(stepMinutes.Length, thresholds.Length);
Assert.All(thresholds, t => Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), t));
}
[Fact]
public void A_single_sample_has_no_interval_to_judge()
{
Assert.Empty(InstantRateNormalizer.SampleGapThresholds([Measured(Utc(2024, 6, 1), 1)]));
}
[Fact]
public void Hourly_polls_a_few_seconds_late_after_fast_samples_are_not_gaps()
{
// m7 #6: ten-second samples for a while, then a poller every hour, five seconds late each time.
var samples = Enumerable.Range(0, 360).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList();
var last = samples[^1].Time;
samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(last.AddSeconds(3605 * i), 2)));
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples));
Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None);
Assert.Equal(2 * (samples[^1].Time - samples[0].Time).TotalHours, result.Sum(c => c.Amount), 6);
}
[Fact]
public void A_silence_barely_over_an_hour_between_fast_samples_is_within_the_poll_slack()
{
var samples = Enumerable.Range(0, 30).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList();
var silent = samples[^1].Time;
samples.Add(Measured(silent.AddSeconds(3630), 2));
samples.Add(Measured(silent.AddSeconds(3640), 2));
samples.Add(Measured(silent.AddSeconds(3640 + 3662), 2));
samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(silent.AddSeconds(3640 + 3662 + (10 * i)), 2)));
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples));
// An hour and thirty seconds is an hourly interval; an hour and a minute and two seconds is not.
var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap);
Assert.Equal(silent.AddSeconds(3640), gap.IntervalStart);
}
[Fact]
public void An_outage_is_judged_by_the_rhythm_around_it_not_by_the_meters_whole_history()
{
// A year of hourly polls would put the whole-history threshold at ten hours and hide a five-hour
// outage of the five-minute sensor that replaced them; the rhythm around the outage finds it.
var hourly = Enumerable.Range(0, 200).Select(i => Measured(Utc(2024, 1, 1).AddHours(i), 1));
var fastStart = Utc(2024, 1, 1).AddHours(199);
var fast = Enumerable.Range(1, 60).Select(i => Measured(fastStart.AddMinutes(5 * i), 1)).ToList();
var resumed = fast[^1].Time.AddHours(5);
var after = Enumerable.Range(0, 60).Select(i => Measured(resumed.AddMinutes(5 * i), 1));
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, [.. hourly, .. fast, .. after]));
var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap);
Assert.Equal(fast[^1].Time, gap.IntervalStart);
Assert.Equal(resumed, gap.IntervalEnd);
}
// ---- Direct delta ----------------------------------------------------------------------------
[Fact]
public void Direct_delta_rows_cover_the_time_since_the_previous_report()
{
var result = _engine.Normalize(Context(MeterMode.DirectDelta, Berlin,
[
Manual(InBerlin(2026, 9, 1, 6), 5),
Manual(InBerlin(2026, 9, 1, 12), 3),
Manual(InBerlin(2026, 9, 2), 4),
]));
Assert.True(result[0].OpeningBalance);
Assert.Equal(result[0].IntervalEnd, result[0].IntervalStart);
Assert.Equal([InBerlin(2026, 9, 1, 6), InBerlin(2026, 9, 1, 12)], result.Skip(1).Select(c => c.IntervalStart!.Value));
Assert.Equal(InBerlin(2026, 9, 1, 23, 59, 59), result[2].Time);
Assert.Equal([5d, 3d, 4d], result.Select(c => c.Amount));
}
[Fact]
public void A_first_direct_delta_is_an_opening_balance_even_when_the_install_date_is_known()
{
// m7 #1: the first increment covers one reporting step nobody recorded, not the years since the meter
// was installed — otherwise 0.05 kWh would claim coverage all the way back to 2020.
var first = Assert.Single(_engine.Normalize(Context(
MeterMode.DirectDelta, Berlin, [Measured(InBerlin(2026, 9, 19, 10), 0.05)], installedAt: new DateOnly(2020, 1, 1))));
Assert.True(first.OpeningBalance);
Assert.Equal(InBerlin(2026, 9, 19, 10), first.IntervalStart);
Assert.Equal(first.IntervalEnd, first.IntervalStart);
Assert.Empty(MeterVault.Core.Analysis.Coverage.CoverageBuilder.Build([first], Berlin));
}
[Fact]
public void A_register_still_starts_its_first_reading_at_the_install_date()
{
var register = Assert.Single(_engine.Normalize(Context(
MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 1, 6), 5)], installedAt: new DateOnly(2026, 8, 31))));
Assert.False(register.OpeningBalance);
Assert.Equal(InBerlin(2026, 8, 31), register.IntervalStart);
}
[Fact]
public void A_direct_delta_month_row_covers_its_month_whatever_came_before_it()
{
// Behind UTC the month rows are stamped at the local month start, and still cover whole months.
var result = _engine.Normalize(Context(MeterMode.DirectDelta, NewYork,
[
Manual(Local(NewYork, 2026, 6, 20, 12), 7),
Reading(1, Month(2026, 7), 960),
Reading(1, Month(2026, 8), 1000),
]));
var months = result.Where(c => c.Amount >= 960).ToList();
Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.IntervalStart!.Value));
Assert.Equal([Local(NewYork, 2026, 8, 1), Local(NewYork, 2026, 9, 1)], months.Select(c => c.IntervalEnd!.Value));
Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.Time));
Assert.DoesNotContain(months, c => c.OpeningBalance);
}
// ---- Coalesce --------------------------------------------------------------------------------
[Fact]
public void Rows_merged_onto_one_stamp_describe_both_intervals_and_keep_every_warning()
{
var at = Utc(2026, 8, 31, 21, 59);
var merged = Assert.Single(NormalizationEngine.Coalesce(
[
new Consumption
{
MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddHours(-2), IntervalEnd = at, Divided = true,
SourceStart = at.AddDays(-2), SourceEnd = at.AddMinutes(1),
},
new Consumption
{
MeterId = 1, Time = at, Amount = 2, IntervalStart = at.AddHours(-5), IntervalEnd = at.AddHours(1),
SourceStart = at.AddHours(-5), SourceEnd = at.AddHours(1), OpeningBalance = true, Gap = CoverageGapReason.SampleGap,
},
]));
Assert.Equal(3, merged.Amount);
Assert.Equal(at.AddHours(-5), merged.IntervalStart);
Assert.Equal(at.AddHours(1), merged.IntervalEnd);
Assert.Equal(at.AddDays(-2), merged.SourceStart);
Assert.Equal(at.AddHours(1), merged.SourceEnd);
Assert.False(merged.Divided);
Assert.True(merged.OpeningBalance);
Assert.Equal(CoverageGapReason.SampleGap, merged.Gap);
Assert.Equal(ReadingQuality.Estimated, merged.Quality);
}
[Fact]
public void Two_divided_shares_merged_onto_one_stamp_stay_a_divided_share()
{
var at = Utc(2026, 8, 31, 21, 59);
var merged = Assert.Single(NormalizationEngine.Coalesce(
[
new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-3), IntervalEnd = at.AddSeconds(1), Divided = true },
new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-1), IntervalEnd = at.AddSeconds(1), Divided = true },
]));
Assert.True(merged.Divided);
Assert.Equal(at.AddDays(-3), merged.IntervalStart);
}
[Fact]
public void A_live_reading_merged_with_a_month_row_spans_the_whole_month()
{
// New York: HA polled at exactly the local midnight the "Juli" row is stamped at. The two rows share
// one key; the merged row covers July.
var julyMidnight = Local(NewYork, 2026, 7, 1);
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, NewYork,
[
Reading(1, Month(2026, 6), 1000),
Reading(1, Month(2026, 7), 1300),
Measured(julyMidnight, 1005),
]));
var july = Assert.Single(result, c => c.Time == julyMidnight);
Assert.Equal(300, july.Amount, 9);
Assert.Equal(julyMidnight, july.IntervalStart);
Assert.Equal(Local(NewYork, 2026, 8, 1), july.IntervalEnd);
}
}
@@ -0,0 +1,163 @@
using MeterVault.Core.Analysis.Virtual;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The formula grammar is user input evaluated on every read, so the parser is the safety boundary (D-26): it
/// accepts only numbers, <c>m&lt;id&gt;</c>, unary minus, <c>+ - * /</c> and parentheses, reports every rejection
/// with a position instead of throwing, and cannot be driven into a stack overflow. Ported from the old
/// ExpressionEvaluatorTests; the one deliberate change is that an unknown identifier is now an error, not 0.
/// </summary>
public sealed class FormulaParserTests
{
private static double Evaluate(string text) =>
Formula.Parse(text).Evaluate(id => id switch { 1 => 411, 2 => 416, _ => throw new KeyNotFoundException($"m{id}") });
[Theory]
[InlineData("1 + 2 * 3", 7)]
[InlineData("(1 + 2) * 3", 9)]
[InlineData("-5", -5)]
[InlineData("--5", 5)]
[InlineData("+5", 5)]
[InlineData("2 * -3", -6)]
[InlineData("-(1 + 2) * 3", -9)]
[InlineData("10 / 4", 2.5)]
[InlineData("2 - 3 - 4", -5)] // left-associative
[InlineData("8 / 4 / 2", 1)] // left-associative
[InlineData("0.5 * 4", 2)]
[InlineData(".5 + 5.", 5.5)]
[InlineData("m1 - m2", -5)] // Netz Einsparung Okt 2022: Haus 411 − Netz 416
[InlineData("m1 + m2", 827)]
[InlineData("m1+m2", 827)]
[InlineData(" ( m1 ) - m2 ", -5)]
public void Evaluates_arithmetic_over_numbers_and_meter_references(string text, double expected)
{
Assert.Equal(expected, Evaluate(text), 9);
}
[Fact]
public void Numbers_are_invariant_decimals_whatever_the_current_culture()
{
var previous = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
Assert.Equal(0.25, Formula.Parse("0.25").Evaluate(_ => 0), 12);
Assert.Equal(FormulaErrorKind.UnexpectedCharacter, FormulaParser.Parse("0,25").Error!.Kind);
}
finally
{
Thread.CurrentThread.CurrentCulture = previous;
}
}
[Theory]
[InlineData("1 +", FormulaErrorKind.UnexpectedEnd, 3, null)]
[InlineData("(1 + 2", FormulaErrorKind.MissingClosingParenthesis, 0, "(")]
[InlineData("m1 + (m2 * (3 - 1)", FormulaErrorKind.MissingClosingParenthesis, 5, "(")]
[InlineData("1 2", FormulaErrorKind.UnexpectedToken, 2, "2")]
[InlineData("m1 m2", FormulaErrorKind.UnexpectedToken, 3, "m2")]
[InlineData(")", FormulaErrorKind.UnexpectedToken, 0, ")")]
[InlineData("(m1))", FormulaErrorKind.UnexpectedToken, 4, ")")]
[InlineData("m1 * * m2", FormulaErrorKind.UnexpectedToken, 5, "*")]
[InlineData("1 % 2", FormulaErrorKind.UnexpectedCharacter, 2, "%")]
[InlineData("1.2.3", FormulaErrorKind.InvalidNumber, 0, "1.2.3")]
[InlineData(".", FormulaErrorKind.InvalidNumber, 0, ".")]
[InlineData("1e3", FormulaErrorKind.UnexpectedToken, 1, "e3")]
[InlineData("2m1", FormulaErrorKind.UnexpectedToken, 1, "m1")]
[InlineData("m99999999999", FormulaErrorKind.MeterIdOutOfRange, 0, "m99999999999")]
public void Rejects_malformed_formulas_with_the_position_and_token(string text, FormulaErrorKind kind, int position, string? token)
{
var result = FormulaParser.Parse(text);
Assert.False(result.Success);
Assert.Equal(new FormulaError(kind, position, token), result.Error);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_text_is_an_error_not_an_exception(string? text)
{
Assert.Equal(FormulaErrorKind.Empty, FormulaParser.Parse(text).Error!.Kind);
}
[Theory]
[InlineData("unknown + 1", "unknown", 0)] // the old evaluator read this as 1
[InlineData("m1 - n2", "n2", 5)]
[InlineData("M1 + m2", "M1", 0)]
[InlineData("m1a", "m1a", 0)]
[InlineData("m_1", "m_1", 0)]
[InlineData("m", "m", 0)]
[InlineData("sum(m1)", "sum", 0)]
[InlineData("m1 + Hausverbrauch", "Hausverbrauch", 5)]
public void Only_m_digits_identifiers_are_meter_references_and_anything_else_is_named(string text, string identifier, int position)
{
Assert.Equal(new FormulaError(FormulaErrorKind.UnknownIdentifier, position, identifier), FormulaParser.Parse(text).Error);
}
[Fact]
public void Nesting_64_parentheses_deep_is_accepted_and_65_is_rejected_at_the_65th()
{
static string Nested(int depth) => new string('(', depth) + "m1" + new string(')', depth);
Assert.True(FormulaParser.Parse(Nested(FormulaParser.MaxDepth)).Success);
var tooDeep = FormulaParser.Parse(Nested(FormulaParser.MaxDepth + 1));
Assert.Equal(new FormulaError(FormulaErrorKind.TooDeep, 64, "("), tooDeep.Error);
}
[Fact]
public void Two_thousand_characters_are_accepted_and_2001_rejected()
{
var chain = "m1" + string.Concat(Enumerable.Repeat(" + m1", 399)); // 1,997 characters
var exactly = chain + " ";
Assert.Equal(FormulaParser.MaxLength, exactly.Length);
var accepted = FormulaParser.Parse(exactly);
Assert.True(accepted.Success);
Assert.Equal(400 * 411, accepted.Formula.Evaluate(_ => 411), 6);
Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(exactly + " ").Error!.Kind);
}
[Fact]
public void A_hundred_thousand_open_parentheses_are_rejected_without_overflowing_the_stack()
{
Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(new string('(', 100_000)).Error!.Kind);
Assert.Equal(FormulaErrorKind.TooDeep, FormulaParser.Parse(new string('(', 1_999)).Error!.Kind);
}
[Fact]
public void Long_operator_chains_and_sign_runs_parse_and_evaluate_without_recursion()
{
var signs = FormulaParser.Parse(new string('-', 1_999) + "1");
Assert.True(signs.Success);
Assert.Equal(-1, signs.Formula.Evaluate(_ => 0));
var product = FormulaParser.Parse("1" + string.Concat(Enumerable.Repeat(" * 1", 499)));
Assert.True(product.Success);
Assert.Equal(1, product.Formula.Evaluate(_ => 0));
Assert.Equal(product.Formula, Formula.Parse(product.Formula.ToString()));
}
[Fact]
public void Scanning_finds_meter_tokens_even_in_text_that_does_not_parse()
{
Assert.Equal([2, 5, 12], FormulaParser.ScanMeterIds("m12 + (m5 * m2 +"));
Assert.Empty(FormulaParser.ScanMeterIds("1.5 + x2"));
Assert.Empty(FormulaParser.ScanMeterIds(null));
}
[Fact]
public void Rewriting_ids_in_text_keeps_the_users_spacing_and_even_a_syntax_error()
{
var map = new Dictionary<int, int> { [1] = 41, [2] = 42 };
Assert.Equal("m41 -( m42 )", FormulaParser.RewriteMeterIds("m1 -( m2 )", id => map[id]));
Assert.Equal("m41 + ", FormulaParser.RewriteMeterIds("m1 + ", id => map[id]));
Assert.Equal("0.1m99 + xm1", FormulaParser.RewriteMeterIds("0.1m2 + xm1", _ => 99)); // "xm1" names no meter
}
}
+158
View File
@@ -0,0 +1,158 @@
using MeterVault.Core.Analysis.Virtual;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A parsed formula knows its own shape: which meters it reads, whether it is linear (additive, so buckets sum to
/// the total and the quantity can be priced) or a pure sum (so costs can be the sources' own), and a canonical text
/// that parses back to the same formula.
/// </summary>
public sealed class FormulaTests
{
[Fact]
public void Meter_ids_are_distinct_and_ascending_and_references_keep_their_positions()
{
var formula = Formula.Parse("m12 + m3 - m12 * 2");
Assert.Equal([3, 12], formula.MeterIds);
Assert.Equal(
[new FormulaReference(12, 0, 3), new FormulaReference(3, 6, 2), new FormulaReference(12, 11, 3)],
formula.References);
}
[Theory]
[InlineData("m1 + m2", true, true)]
[InlineData("(m1 + m2) * 1", true, true)]
[InlineData("m1 - m2", true, false)]
[InlineData("m1 + m1", true, false)]
[InlineData("2 * (m1 + m2)", true, false)]
[InlineData("0.5 * m1 + m2 / 4", true, false)]
[InlineData("-(m1 - m2)", true, false)]
[InlineData("m1 + 5 - 5", true, true)] // the constants cancel
[InlineData("m1 + 5", false, false)] // a constant term: not additive across buckets
[InlineData("m1 * m2", false, false)]
[InlineData("m1 / m2", false, false)]
[InlineData("1 / m1", false, false)]
[InlineData("m1 / 0", false, false)]
[InlineData("m1 * (1 / 0)", false, false)]
[InlineData("(m1 - m1) * m2", false, false)] // decided by structure, not by the value that cancels
public void Linearity_and_pure_sums_are_recognised(string text, bool linear, bool pureSum)
{
var formula = Formula.Parse(text);
Assert.Equal(linear, formula.IsLinear);
Assert.Equal(pureSum, formula.IsPureSum);
Assert.Equal(linear, formula.Coefficients is not null);
}
[Fact]
public void Coefficients_are_each_meters_weight_in_a_linear_formula()
{
Assert.Equal(new Dictionary<int, double> { [1] = 1, [2] = -1 }, Formula.Parse("m1 - m2").Coefficients);
Assert.Equal(new Dictionary<int, double> { [1] = 0.5, [2] = 0.25 }, Formula.Parse("0.5 * m1 + m2 / 4").Coefficients);
Assert.Equal(new Dictionary<int, double> { [1] = -1, [2] = 1 }, Formula.Parse("-(m1 - m2)").Coefficients);
Assert.Equal(new Dictionary<int, double> { [1] = 0, [2] = 1 }, Formula.Parse("m1 - m1 + m2").Coefficients);
}
[Theory]
[InlineData("m1+m2", "m1 + m2")]
[InlineData("((((m1))))", "m1")]
[InlineData("(m1 + m2) * 2", "(m1 + m2) * 2")]
[InlineData("(m1 - m2) - m3", "m1 - m2 - m3")]
[InlineData("m1 - (m2 - m3)", "m1 - (m2 - m3)")]
[InlineData("m1 - (m2 + m3)", "m1 - (m2 + m3)")]
[InlineData("m1 + (m2 + m3)", "m1 + (m2 + m3)")]
[InlineData("m1 / (m2 * m3)", "m1 / (m2 * m3)")]
[InlineData("m1 * m2 + m3", "m1 * m2 + m3")]
[InlineData("-(m1 + m2)", "-(m1 + m2)")]
[InlineData("-m1 * m2", "-m1 * m2")]
[InlineData("--m1", "--m1")]
[InlineData("+m1", "m1")]
[InlineData("m1 * -m2", "m1 * -m2")]
[InlineData("m1 - -5", "m1 - -5")]
[InlineData("0.50 * m1", "0.50 * m1")]
public void The_canonical_text_has_minimal_parentheses_and_parses_back_to_an_equal_formula(string text, string canonical)
{
var formula = Formula.Parse(text);
Assert.Equal(canonical, formula.ToString());
Assert.Equal(text, formula.Text);
Assert.Equal(formula, Formula.Parse(formula.ToString()));
}
[Fact]
public void Equality_is_structural()
{
Assert.Equal(Formula.Parse("m1+m2"), Formula.Parse("(m1) + m2"));
Assert.Equal(Formula.Parse("m1+m2").GetHashCode(), Formula.Parse("(m1) + m2").GetHashCode());
Assert.Equal(Formula.Parse("1.0 * m1"), Formula.Parse("1 * m1"));
Assert.NotEqual(Formula.Parse("m1 + m2"), Formula.Parse("m2 + m1"));
Assert.NotEqual(Formula.Parse("m1 - m2 - m3"), Formula.Parse("m1 - (m2 - m3)"));
}
[Fact]
public void The_tree_mirrors_precedence()
{
var root = Assert.IsType<BinaryNode>(Formula.Parse("m1 - 2 * m2").Root);
Assert.Equal(FormulaOperator.Subtract, root.Operator);
Assert.Equal(1, Assert.IsType<MeterReferenceNode>(root.Left).MeterId);
var product = Assert.IsType<BinaryNode>(root.Right);
Assert.Equal(FormulaOperator.Multiply, product.Operator);
Assert.Equal(2, Assert.IsType<NumberNode>(product.Left).Value);
Assert.Equal(2, Assert.IsType<MeterReferenceNode>(product.Right).MeterId);
}
[Fact]
public void Division_by_zero_evaluates_to_a_non_finite_number_for_the_caller_to_judge()
{
var ratio = Formula.Parse("m1 / m2");
Assert.True(double.IsPositiveInfinity(ratio.Evaluate(id => id == 1 ? 80 : 0)));
Assert.True(double.IsNaN(ratio.Evaluate(_ => 0)));
}
[Fact]
public void Rewriting_ids_follows_meters_to_new_ids_and_keeps_the_users_text()
{
var map = new Dictionary<int, int> { [1] = 41, [2] = 42 };
var formula = Formula.Parse("m1 + (m2*m1)");
var rewritten = formula.RewriteIds(id => map[id]);
Assert.Equal("m41 + (m42*m41)", rewritten.Text);
Assert.Equal([41, 42], rewritten.MeterIds);
Assert.Equal(Formula.Parse("m41 + m42 * m41"), rewritten);
Assert.Equal(
[new FormulaReference(41, 0, 3), new FormulaReference(42, 8, 3), new FormulaReference(41, 12, 3)],
rewritten.References);
Assert.Equal(Formula.Parse(rewritten.Text), rewritten);
}
[Fact]
public void Rewriting_to_a_negative_id_is_refused()
{
Assert.Throws<ArgumentOutOfRangeException>(() => Formula.Parse("m1").RewriteIds(_ => -1));
}
[Fact]
public void Sum_and_difference_build_the_editors_simple_modes()
{
var sum = Formula.Sum([4, 5, 4]);
Assert.Equal("m4 + m5", sum.ToString());
Assert.True(sum.IsPureSum);
var difference = Formula.Difference(1, [2, 3]);
Assert.Equal("m1 - m2 - m3", difference.ToString());
Assert.Equal(new Dictionary<int, double> { [1] = 1, [2] = -1, [3] = -1 }, difference.Coefficients);
Assert.Throws<ArgumentException>(() => Formula.Sum([]));
Assert.Throws<ArgumentException>(() => Formula.Difference(1, []));
}
[Fact]
public void Parse_throws_only_for_trusted_text_that_turns_out_invalid()
{
Assert.Throws<FormatException>(() => Formula.Parse("m1 +"));
}
}
@@ -0,0 +1,106 @@
using MeterVault.Core.Analysis;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>D-18: the freshness mark is the last reading or event, and only a live source can be stale.</summary>
public sealed class FreshnessRulesTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Without_any_reading_or_event_there_is_no_data()
{
var freshness = FreshnessRules.Evaluate(new FreshnessInput(null, null, [], HasLiveSource: true, null), Now);
Assert.Equal(FreshnessState.NoData, freshness.State);
Assert.Null(freshness.LastActivity);
}
[Fact]
public void An_import_only_meter_is_historical_never_stale()
{
var years = Now.AddYears(-3);
var freshness = FreshnessRules.Evaluate(new FreshnessInput(years, null, [years], HasLiveSource: false, null), Now);
Assert.Equal(FreshnessState.Historical, freshness.State);
Assert.Equal(years, freshness.LastActivity);
}
[Fact]
public void A_live_source_is_stale_after_three_of_its_own_intervals()
{
// Readings every 10 minutes; the last one 40 minutes ago is more than 3 × 10 minutes.
var times = Enumerable.Range(0, 21).Select(i => Now.AddMinutes(-40 - (10 * i))).ToList();
var stale = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), Now);
var fresh = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), times[0].AddMinutes(25));
Assert.Equal(FreshnessState.Stale, stale.State);
Assert.Equal(TimeSpan.FromMinutes(30), stale.StaleAfter);
Assert.Equal(FreshnessState.Live, fresh.State);
}
[Fact]
public void The_poll_interval_keeps_a_rarely_polled_source_live_between_polls()
{
// Two readings a minute apart, but the source is polled hourly: 3 × 60 min wins over 3 × 1 min.
var last = Now.AddMinutes(-90);
var times = new[] { last, last.AddMinutes(-1) };
var freshness = FreshnessRules.Evaluate(new FreshnessInput(last, null, times, HasLiveSource: true, TimeSpan.FromHours(1)), Now);
Assert.Equal(FreshnessState.Live, freshness.State);
Assert.Equal(TimeSpan.FromHours(3), freshness.StaleAfter);
}
[Fact]
public void An_event_after_the_last_reading_is_the_mark()
{
var reading = Now.AddDays(-10);
var delivery = Now.AddHours(-1);
var freshness = FreshnessRules.Evaluate(new FreshnessInput(reading, delivery, [reading], HasLiveSource: false, null), Now);
Assert.Equal(delivery, freshness.LastActivity);
}
[Fact]
public void A_live_source_with_an_unknown_rhythm_is_not_called_stale()
{
var once = Now.AddDays(-30);
var freshness = FreshnessRules.Evaluate(new FreshnessInput(once, null, [once], HasLiveSource: true, null), Now);
Assert.Equal(FreshnessState.Live, freshness.State);
Assert.Null(freshness.StaleAfter);
}
[Fact]
public void The_median_uses_the_latest_twenty_intervals()
{
// 30 readings: the oldest ten are a day apart, the latest 21 an hour apart.
var times = Enumerable.Range(0, 21).Select(i => Now.AddHours(-i))
.Concat(Enumerable.Range(1, 9).Select(i => Now.AddHours(-20).AddDays(-i)))
.ToList();
Assert.Equal(TimeSpan.FromHours(1), FreshnessRules.MedianInterval(times));
Assert.Null(FreshnessRules.MedianInterval([Now]));
}
[Fact]
public void Combined_freshness_is_as_current_as_its_least_current_source()
{
var combined = FreshnessRules.Combine(
[
new Freshness(FreshnessState.Live, Now.AddMinutes(-5)),
new Freshness(FreshnessState.Stale, Now.AddDays(-2)),
Freshness.None,
]);
Assert.Equal(FreshnessState.Stale, combined.State);
Assert.Equal(Now.AddDays(-2), combined.LastActivity);
Assert.Equal(FreshnessState.Historical, FreshnessRules.Combine([new Freshness(FreshnessState.Historical, Now)]).State);
Assert.Equal(FreshnessState.NoData, FreshnessRules.Combine([]).State);
}
}
@@ -0,0 +1,166 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The legacy entry points' ranges (D-45): the REST API's exact instants and the older services' date ranges become
/// ordinary resolved periods, cut at now, that the bucket planner tiles exactly.
/// </summary>
public sealed class LegacyPeriodsTests
{
private static readonly DateTimeOffset Now = At(Berlin, 2026, 9, 19, 14, 37);
[Fact]
public void Utc_midnights_keep_their_exact_instants_in_a_zone_ahead_of_utc()
{
// The contract test's bounds: 2024-01-01T00:00Z is 01:00 in Berlin, and 2024-03-01T00:00Z is 01:00 on 1 March.
var period = LegacyPeriods.FromInstants(Utc(2024, 1, 1), Utc(2024, 3, 1), Now, Berlin);
Assert.Equal((Day(2024, 1, 1), Day(2024, 3, 1)), (period.FirstDay, period.LastDay));
Assert.Equal((Utc(2024, 1, 1), Utc(2024, 3, 1)), (period.From, period.To));
Assert.False(period.IsToDate);
Assert.False(period.HasNotStarted());
var plan = BucketPlanner.Plan(period, BucketSize.Month);
// January from 01:00, February whole, and the first hour of 1 March — the buckets tile [from, to) exactly.
Assert.Equal(3, plan.Buckets.Count);
Assert.Equal(Utc(2024, 1, 1), plan.Buckets[0].From);
Assert.Equal(At(Berlin, 2024, 2, 1), plan.Buckets[0].To);
Assert.Equal((At(Berlin, 2024, 3, 1), Utc(2024, 3, 1)), (plan.Buckets[2].From, plan.Buckets[2].To));
Assert.Equal([Day(2024, 1, 1), Day(2024, 2, 1), Day(2024, 3, 1)], plan.Buckets.Select(LegacyPeriods.KeyOf));
}
[Fact]
public void Local_midnights_give_whole_months()
{
var period = LegacyPeriods.FromInstants(At(Berlin, 2024, 1, 1), At(Berlin, 2024, 3, 1), Now, Berlin);
Assert.Equal((Day(2024, 1, 1), Day(2024, 2, 29)), (period.FirstDay, period.LastDay));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(2, plan.Buckets.Count);
Assert.Equal(period, PeriodResolver.Resolve(PeriodPreset.Custom, Day(2024, 1, 1), Day(2024, 2, 29), Now, Berlin));
}
[Fact]
public void An_offset_is_read_as_the_instant_it_names()
{
var withOffset = LegacyPeriods.FromInstants(
new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.FromHours(1)), Now, Berlin);
Assert.Equal(TimeSpan.Zero, withOffset.From.Offset);
Assert.Equal(At(Berlin, 2024, 1, 1), withOffset.From);
Assert.Equal(At(Berlin, 2024, 3, 1), withOffset.To);
}
[Fact]
public void A_range_reaching_past_now_stops_at_now()
{
// D-04: a legacy caller asking for this month and the next still gets actuals up to now only.
var period = LegacyPeriods.FromInstants(At(Berlin, 2026, 9, 1), At(Berlin, 2026, 11, 1), Now, Berlin);
Assert.True(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.Equal(Now, period.To);
Assert.Equal(Day(2026, 10, 31), period.LastDay);
var plan = BucketPlanner.Plan(period, BucketSize.Month);
var bucket = Assert.Single(plan.Buckets);
Assert.Equal(Now, bucket.To);
}
[Fact]
public void A_range_after_now_has_not_started_and_plans_nothing()
{
var period = LegacyPeriods.FromInstants(At(Berlin, 2026, 10, 1), At(Berlin, 2026, 11, 1), Now, Berlin);
Assert.True(period.HasNotStarted());
Assert.Equal(period.From, period.To);
Assert.Empty(BucketPlanner.Plan(period, BucketSize.Month).Buckets);
Assert.Empty(LegacyPeriods.WholePeriodPlan(period).Buckets);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void An_empty_or_inverted_range_plans_nothing(int days)
{
var from = At(Berlin, 2024, 5, 1);
var period = LegacyPeriods.FromInstants(from, from.AddDays(days), Now, Berlin);
Assert.True(period.HasNoHistory());
Assert.Empty(BucketPlanner.Plan(period, BucketSize.Month).Buckets);
}
[Fact]
public void Instants_outside_the_supported_dates_are_clamped()
{
var period = LegacyPeriods.FromInstants(DateTimeOffset.MinValue, DateTimeOffset.MaxValue, Now, Berlin);
Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay);
Assert.Equal(At(Berlin, 1900, 1, 1), period.From);
Assert.Equal(Now, period.To);
Assert.Equal(PeriodResolver.MaxSupportedDate, period.LastDay);
Assert.False(BucketPlanner.Plan(period, BucketSize.Year).Refused);
}
[Fact]
public void A_date_range_ends_the_day_before_its_exclusive_end_and_is_cut_at_now()
{
// The dashboard's "this year": [1 January, 1 January next year).
var year = LegacyPeriods.FromDates(Day(2026, 1, 1), Day(2027, 1, 1), Now, Berlin);
Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (year.FirstDay, year.LastDay));
Assert.Equal(At(Berlin, 2026, 1, 1), year.From);
Assert.Equal(Now, year.To);
Assert.Equal(BucketSize.Year, PeriodBucket.Of(year).Size);
var previous = LegacyPeriods.FromDates(Day(2025, 1, 1), Day(2026, 1, 1), Now, Berlin);
Assert.False(previous.IsToDate);
Assert.Equal(At(Berlin, 2026, 1, 1), previous.To);
}
[Fact]
public void A_date_range_in_a_zone_behind_utc_starts_at_local_midnight()
{
var period = LegacyPeriods.FromDates(Day(2026, 7, 1), Day(2026, 8, 1), Now, NewYork);
Assert.Equal(Utc(2026, 7, 1, 4), period.From);
Assert.Equal(Utc(2026, 8, 1, 4), period.To);
}
[Fact]
public void An_empty_date_range_or_one_outside_the_supported_dates_is_handled()
{
Assert.True(LegacyPeriods.FromDates(Day(2026, 5, 1), Day(2026, 5, 1), Now, Berlin).HasNoHistory());
Assert.True(LegacyPeriods.FromDates(Day(2026, 5, 2), Day(2026, 5, 1), Now, Berlin).HasNoHistory());
var wide = LegacyPeriods.FromDates(new DateOnly(1, 1, 1), new DateOnly(9999, 1, 1), Now, Berlin);
Assert.Equal((PeriodResolver.MinSupportedDate, PeriodResolver.MaxSupportedDate), (wide.FirstDay, wide.LastDay));
}
[Fact]
public void The_whole_period_plan_is_the_period_as_one_bucket()
{
var period = LegacyPeriods.FromDates(Day(2025, 1, 1), Day(2026, 1, 1), Now, Berlin);
var plan = LegacyPeriods.WholePeriodPlan(period);
Assert.False(plan.Refused);
Assert.Equal(BucketSize.Year, plan.Size);
Assert.Equal(PeriodBucket.Of(period), Assert.Single(plan.Buckets));
}
[Theory]
[InlineData(BucketSize.Day, "2026-09-17")]
[InlineData(BucketSize.Week, "2026-09-14")]
[InlineData(BucketSize.Month, "2026-09-01")]
[InlineData(BucketSize.Year, "2026-01-01")]
public void A_bucket_is_filed_under_the_start_of_its_calendar_unit(BucketSize size, string key)
{
var bucket = new AnalysisBucket(Day(2026, 9, 17), Day(2026, 9, 18), At(Berlin, 2026, 9, 17), At(Berlin, 2026, 9, 18), size);
Assert.Equal(Iso(key), LegacyPeriods.KeyOf(bucket));
}
}
@@ -0,0 +1,250 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Expression-less virtual meters get the explicit sum their incoming links implied, when that sum is
/// unambiguous — one unit, one kind, same energy type — and are flagged "needs configuration" otherwise (D-28).
/// The seeded Summe Solar is the reference case.
/// </summary>
public sealed class LegacyVirtualDerivationTests
{
[Fact]
public void Seeded_summe_solar_becomes_solar_1_plus_solar_2_generation_in_kWh()
{
var result = LegacyVirtualDerivation.Derive(6, SeededLinks(), new MeterCatalog(SeededElectricity()));
Assert.Equal(LegacyDerivationOutcome.Derived, result.Outcome);
// A generation sum is not costed (A-15): generation is never billed, so its sources have no metered cost.
Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), result.Definition);
Assert.Equal([4, 5], result.MeterIds);
Assert.Empty(result.IgnoredMeterIds);
}
[Fact]
public void Hundreds_of_incoming_links_are_a_finding_not_an_exception()
{
// Review virtual F3: 300 links into one sum render as "m1000 + … + m1299", longer than a formula may be. That is
// this meter's configuration problem; it must never throw out of the derivation and fail every other meter.
var catalog = new MeterCatalog(
[.. Enumerable.Range(1000, 300).Select(id => Physical(id, $"PV {id}", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh")),
new CatalogMeter(5000, "Sum", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1)]);
MeterLink[] links = [.. Enumerable.Range(1000, 300).Select(id => Link(id, 5000))];
var run = LegacyVirtualDerivation.DeriveAll([5000], links, catalog);
var result = Assert.Single(run.Results);
Assert.Equal(LegacyDerivationOutcome.Invalid, result.Outcome);
Assert.True(result.NeedsConfiguration);
Assert.Equal(["Syntax"], result.Values);
Assert.Null(result.Definition);
// 250 sources still fit, and are derived.
var fewer = LegacyVirtualDerivation.Derive(5000, links[..250], catalog);
Assert.Equal(LegacyDerivationOutcome.Derived, fewer.Outcome);
Assert.Equal(250, fewer.Definition!.ReferencedMeterIds.Count);
}
[Fact]
public void Its_outgoing_topology_link_is_not_part_of_the_calculation()
{
// Summe Solar → Haus is flow topology; Haus gets no calculation from it, and Summe none from Netz → Haus.
var catalog = new MeterCatalog(SeededElectricity());
var derived = LegacyVirtualDerivation.Derive(6, SeededLinks(), catalog).Definition!;
Assert.DoesNotContain(1, derived.ReferencedMeterIds);
Assert.True(VirtualValidator.Validate(derived, 6, catalog).IsValid);
}
[Fact]
public void Rerunning_the_derivation_changes_nothing()
{
var catalog = new MeterCatalog(SeededElectricity());
var first = LegacyVirtualDerivation.Derive(6, SeededLinks(), catalog).Definition!;
var converted = catalog.With(catalog.Find(6)! with { Definition = first });
var second = LegacyVirtualDerivation.Derive(6, SeededLinks(), converted);
var rerun = LegacyVirtualDerivation.DeriveAll([6], SeededLinks(), converted);
Assert.Equal(LegacyDerivationOutcome.AlreadyDefined, second.Outcome);
Assert.Null(second.Definition);
Assert.Equal((0, 0, 1), (rerun.Converted, rerun.NeedsConfiguration, rerun.Unchanged));
}
[Fact]
public void An_explicit_definition_is_never_replaced_by_one_derived_from_links()
{
// Summe Solar deliberately defined as Solar 1 only: its two incoming links must not turn it back into a sum.
var catalog = new MeterCatalog(SeededElectricity());
var explicitOnly = catalog.With(catalog.Find(6)! with { Definition = new VirtualDefinition("m4", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts) });
var result = LegacyVirtualDerivation.Derive(6, SeededLinks(), explicitOnly);
Assert.Equal(LegacyDerivationOutcome.AlreadyDefined, result.Outcome);
Assert.Null(result.Definition);
Assert.False(result.NeedsConfiguration);
}
[Fact]
public void A_water_sum_is_written_in_the_canonical_unit()
{
var catalog = new MeterCatalog(
[
Physical(60, "Wasser Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2),
Physical(61, "Wasser Garten", MeterMode.CumulativeCounter, QuantityKind.Consumption, "M³", energyType: 2),
new CatalogMeter(62, "Wasser gesamt", MeterMode.Virtual, QuantityKind.Consumption, "m3", 2),
]);
var result = LegacyVirtualDerivation.Derive(62, [Link(60, 62), Link(61, 62)], catalog);
Assert.Equal(LegacyDerivationOutcome.Derived, result.Outcome);
Assert.Equal("m³", result.Definition!.ResultUnit);
}
[Fact]
public void Runtime_sources_need_configuration_because_runtime_is_no_virtual_result_kind()
{
var catalog = new MeterCatalog(
[
Physical(70, "Brenner 1", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3),
Physical(71, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3),
new CatalogMeter(72, "Brenner gesamt", MeterMode.Virtual, QuantityKind.Consumption, "h", 3),
]);
var result = LegacyVirtualDerivation.Derive(72, [Link(70, 72), Link(71, 72)], catalog);
Assert.Equal(LegacyDerivationOutcome.UnsupportedKind, result.Outcome);
Assert.Equal(["runtime"], result.Values);
Assert.True(result.NeedsConfiguration);
}
[Fact]
public void Sources_of_different_units_need_configuration_and_are_named()
{
// Heating oil: the tank measures litres, the burner hours — their "sum" means nothing.
var catalog = new MeterCatalog(
[
Physical(30, "Öltank", MeterMode.ConsumableBalance, QuantityKind.Consumption, "L", energyType: 3),
Physical(31, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Consumption, "h", energyType: 3),
new CatalogMeter(32, "Heizung gesamt", MeterMode.Virtual, QuantityKind.Consumption, "L", 3),
]);
var result = LegacyVirtualDerivation.Derive(32, [Link(30, 32), Link(31, 32)], catalog);
Assert.Equal(LegacyDerivationOutcome.MixedUnits, result.Outcome);
Assert.Null(result.Definition);
Assert.Equal([30, 31], result.MeterIds);
Assert.Equal(["L", "h"], result.Values);
}
[Fact]
public void Consumption_plus_generation_is_ambiguous_and_needs_configuration()
{
var catalog = new MeterCatalog(SeededElectricity());
var result = LegacyVirtualDerivation.Derive(6, [Link(1, 6), Link(4, 6)], catalog);
Assert.Equal(LegacyDerivationOutcome.MixedKinds, result.Outcome);
Assert.Equal(["consumption", "generation"], result.Values);
}
[Fact]
public void Links_from_another_energy_type_are_ignored_not_summed()
{
var catalog = new MeterCatalog(SeededElectricity());
var result = LegacyVirtualDerivation.Derive(6, [.. SeededLinks(), Link(7, 6)], catalog);
Assert.Equal("m4 + m5", result.Definition!.Expression);
Assert.Equal([7], result.IgnoredMeterIds);
}
[Fact]
public void A_virtual_meter_without_incoming_links_needs_configuration()
{
var result = LegacyVirtualDerivation.Derive(6, [Link(6, 1)], new MeterCatalog(SeededElectricity()));
Assert.Equal(LegacyDerivationOutcome.NoSources, result.Outcome);
}
[Fact]
public void A_physical_meter_is_not_derived()
{
Assert.Equal(LegacyDerivationOutcome.NotVirtual, LegacyVirtualDerivation.Derive(1, SeededLinks(), new MeterCatalog(SeededElectricity())).Outcome);
}
[Fact]
public void Nested_legacy_meters_are_converted_in_dependency_order()
{
// 40 = links from Solar 1 and Solar 2; 41 = links from 40 and a third generation meter 42.
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
new CatalogMeter(40, "PV Dach", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1),
new CatalogMeter(41, "PV gesamt", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1),
Physical(42, "Balkonkraftwerk", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"),
]);
MeterLink[] links = [Link(4, 40), Link(5, 40), Link(40, 41), Link(42, 41)];
// Alone, 41 has to wait for 40.
Assert.Equal(LegacyDerivationOutcome.SourceNeedsConfiguration, LegacyVirtualDerivation.Derive(41, links, catalog).Outcome);
var run = LegacyVirtualDerivation.DeriveAll([41, 40], links, catalog);
Assert.Equal([40, 41], run.Results.Select(r => r.MeterId));
Assert.Equal("m40 + m42", run.Results[1].Definition!.Expression);
Assert.Equal(2, run.Converted);
Assert.Equal(0, run.NeedsConfiguration);
}
[Fact]
public void A_self_link_does_not_make_a_legacy_meter_a_loop()
{
var run = LegacyVirtualDerivation.DeriveAll([6], [.. SeededLinks(), Link(6, 6)], new MeterCatalog(SeededElectricity()));
var summe = Assert.Single(run.Results);
Assert.Equal(LegacyDerivationOutcome.Derived, summe.Outcome);
Assert.Equal("m4 + m5", summe.Definition!.Expression);
}
[Fact]
public void Links_across_energy_types_neither_order_nor_loop_the_run()
{
// A flow drawing links the electricity sum and a water sum both ways. Derive ignores those links, so the run
// must not report them as a loop either.
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
Physical(60, "Wasser Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m³", energyType: 2),
new CatalogMeter(62, "Wasser gesamt", MeterMode.Virtual, QuantityKind.Consumption, "m³", 2),
]);
var run = LegacyVirtualDerivation.DeriveAll([6, 62], [.. SeededLinks(), Link(60, 62), Link(6, 62), Link(62, 6)], catalog);
Assert.Equal(2, run.Converted);
Assert.Equal("m60", run.Results.Single(r => r.MeterId == 62).Definition!.Expression);
}
[Fact]
public void Legacy_meters_linked_in_a_loop_are_reported_with_the_path()
{
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
new CatalogMeter(50, "X", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1),
new CatalogMeter(51, "Y", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1),
]);
var run = LegacyVirtualDerivation.DeriveAll([50, 51, 6], [.. SeededLinks(), Link(50, 51), Link(51, 50), Link(4, 50)], catalog);
Assert.Equal(1, run.Converted);
Assert.Equal(2, run.NeedsConfiguration);
var x = run.Results.Single(r => r.MeterId == 50);
Assert.Equal(LegacyDerivationOutcome.Cycle, x.Outcome);
Assert.Equal([50, 51, 50], x.MeterIds);
}
}
@@ -0,0 +1,484 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A change figure is confident only over the time both periods cover (D-07). What these pin down: the
/// match follows the current data's actual end rather than the period's; it trims to whole months where
/// either side only has monthly data, and to interval edges inside undivided intervals; a bound on a reading
/// instant keeps the row closing there on the side its time lies on; every source of a combined scope has to
/// agree; holes split the match; an opening balance stays out of it; and no overlap means "not comparable"
/// rather than a percentage against nothing.
/// </summary>
public sealed class MatchedCoverageTests
{
private static readonly DateTimeOffset Now = At(2026, 9, 19, 10);
private static readonly TimeSpan Past = MatchedCoverage.PastBookedRow;
private static DateTimeOffset PreviousYear(DateTimeOffset instant) => ShiftYears(instant, -1);
private static ResolvedPeriod YearToDate(DateTimeOffset now) =>
Period(PeriodPreset.YearToDate, new DateOnly(now.Year, 1, 1), DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime), now);
private static MatchedCoverageResult MatchYearToDate(
DateTimeOffset now,
CoverageRun[] currentRuns,
CoverageRun[] comparisonRuns,
DateTimeOffset[]? currentOpeningBalances = null,
DateTimeOffset[]? comparisonOpeningBalances = null)
{
var current = YearToDate(now);
return MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, currentRuns, currentOpeningBalances),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), now, Berlin, comparisonRuns, comparisonOpeningBalances),
PreviousYear);
}
[Fact]
public void Year_to_date_with_data_until_31_May_matches_January_to_May_in_both_years()
{
var result = MatchYearToDate(Now, [MonthLabels(2026, 1, 2026, 6)], [MonthLabels(2025, 1, 2026, 1)]);
Assert.True(result.IsComparable);
Assert.True(result.IsContiguous);
Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 6, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 5, 31)), result.Current);
Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 6, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 5, 31)), result.Comparison);
}
[Fact]
public void Hourly_data_on_both_sides_matches_up_to_the_cut_at_now()
{
var result = MatchYearToDate(
Now,
[Run(At(2025, 6, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 6, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(Now, result.Current.To);
Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19, 10), result.Comparison!.To);
}
[Fact]
public void Monthly_data_on_the_comparison_side_trims_a_mid_month_cut_to_whole_months_on_both_sides()
{
// Month-resolution data cannot say how much of May had accrued by the 19th.
var now = At(2026, 5, 19, 14, 37);
var result = MatchYearToDate(
now,
[Run(At(2025, 12, 1), At(2026, 5, 19, 14, 37), ResolutionClass.Hour)],
[MonthLabels(2025, 1, 2026, 1)]);
Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 5, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 30)), result.Current);
Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 5, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 30)), result.Comparison);
}
[Fact]
public void Daily_readings_match_up_to_the_last_reading_before_now_and_include_its_row()
{
// Read at 06:00 daily; the reading of 20 September is already stamped. The interval containing now is
// given up (A-04), and the match ends on today's 06:00 reading, whose row closes the day before it.
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 31, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(At(2026, 9, 19, 6) + Past, result.Current.To);
Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19, 6), result.Comparison!.To);
}
[Fact]
public void A_cut_inside_daily_readings_falls_back_to_local_midnight()
{
// The comparison year was read at 06:00 daily; 10:00 on 19 September lies inside one of its days.
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 12, 31, 6), At(2025, 12, 1, 6), ResolutionClass.Day, divided: true)]);
Assert.Equal(At(2026, 9, 19), result.Current!.To);
Assert.Equal(new DateOnly(2026, 9, 18), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19), result.Comparison!.To);
}
[Fact]
public void An_undivided_tank_interval_open_at_the_cut_is_left_out_of_the_match()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 8, 5), ResolutionClass.Month), Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)],
[Run(At(2025, 1, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 8, 5), result.Current!.To);
Assert.Equal(At(2025, 8, 5), result.Comparison!.To);
}
[Fact]
public void Trimming_month_data_never_reaches_back_into_the_long_interval_before_it()
{
// Monthly tank readings resumed on 3 May after a 44-day interval. Floored to 1 May, the cut would
// land inside that interval; the reading on 3 May is the last edge the data can end on.
var now = At(2026, 5, 19, 14);
var result = MatchYearToDate(
now,
[
Run(At(2026, 1, 1), At(2026, 3, 20), ResolutionClass.Month),
Run(At(2026, 3, 20), At(2026, 5, 3), ResolutionClass.Coarse),
Run(At(2026, 5, 3), At(2026, 6, 2), ResolutionClass.Month),
],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 5, 3), result.Current!.To);
Assert.Equal(At(2025, 5, 3), result.Comparison!.To);
}
[Fact]
public void A_match_ending_on_a_dipstick_reading_includes_the_row_booked_at_that_reading()
{
// m2 #3 (P4): the row for 20 March → 3 May is stamped at 3 May 10:00. A query ending exactly there
// would leave those 44 days out of the current side while the comparison year keeps them.
var now = At(2026, 5, 19, 14);
var result = MatchYearToDate(
now,
[
Run(At(2026, 1, 1), At(2026, 3, 20, 10), ResolutionClass.Month),
Single(At(2026, 3, 20, 10), At(2026, 5, 3, 10), ResolutionClass.Coarse),
Single(At(2026, 5, 3, 10), At(2026, 6, 2, 10), ResolutionClass.Month),
],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 5, 3, 10) + Past, result.Current!.To);
Assert.Equal(new DateOnly(2026, 5, 3), result.Current.LastDay);
Assert.Equal(At(2025, 5, 3, 10), result.Comparison!.To);
}
[Fact]
public void A_comparison_month_cut_inside_undivided_dipstick_intervals_is_cut_at_the_dipsticks()
{
// m2 #3 (P7): November 2026 hourly against November 2025, read on 20 October, 18 and 29 November and
// 20 December. 1 November and 1 December lie inside undivided intervals; their dipsticks are the only
// cut points, and the start moves past the row closing at the first one.
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5));
var comparisonRuns = new[]
{
Single(At(2025, 10, 20, 10), At(2025, 11, 18, 10), ResolutionClass.Month),
Single(At(2025, 11, 18, 10), At(2025, 11, 29, 10), ResolutionClass.Month, divided: true),
Single(At(2025, 11, 29, 10), At(2025, 12, 20, 10), ResolutionClass.Month),
};
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), current.Now, Berlin, comparisonRuns),
PreviousYear);
Assert.Equal(new MatchedRange(At(2025, 11, 18, 10) + Past, At(2025, 11, 29, 10) + Past, new DateOnly(2025, 11, 18), new DateOnly(2025, 11, 29)), result.Comparison);
Assert.Equal(new MatchedRange(At(2026, 11, 18, 10), At(2026, 11, 29, 10), new DateOnly(2026, 11, 18), new DateOnly(2026, 11, 29)), result.Current);
}
[Fact]
public void A_comparison_month_that_one_undivided_interval_straddles_on_both_ends_is_not_comparable()
{
// m2 #3 (P7): 14 October → 28 November 10:00 → 20 December. No dipstick falls where November can be cut.
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5));
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]),
MatchSide.Of(
PreviousYear(current.From),
PreviousYear(current.To),
current.Now,
Berlin,
[Single(At(2025, 10, 14), At(2025, 11, 28, 10), ResolutionClass.Coarse), Single(At(2025, 11, 28, 10), At(2025, 12, 20), ResolutionClass.Month)]),
PreviousYear);
Assert.False(result.IsComparable);
}
[Fact]
public void A_combined_scope_starts_its_match_where_every_source_can_be_cut()
{
// m2 #4 (P1): source A books 1 January – 15 March in one interval, then reads daily; source B starts
// hourly on 1 March. Together they cover from 1 March, but A's interval can only be cut at its end.
var current = YearToDate(Now);
IReadOnlyList<CoverageRun>[] currentSources =
[
[Single(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 12, 1), ResolutionClass.Day, divided: true)],
[Run(At(2026, 3, 1), At(2026, 12, 1), ResolutionClass.Hour)],
];
IReadOnlyList<CoverageRun>[] comparisonSources =
[
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
];
var result = MatchedCoverage.Match(
MatchSide.OfSources(current.From, current.To, Now, Berlin, currentSources),
MatchSide.OfSources(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, comparisonSources),
PreviousYear);
Assert.Equal(At(2026, 3, 15), result.Current!.From);
Assert.Equal(At(2025, 3, 15), result.Comparison!.From);
}
[Fact]
public void A_scope_without_sources_is_not_comparable()
{
var current = YearToDate(Now);
var result = MatchedCoverage.Match(
MatchSide.OfSources(current.From, current.To, Now, Berlin, []),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, [MonthLabels(2025, 1, 2026, 1)]),
PreviousYear);
Assert.False(result.IsComparable);
}
[Fact]
public void A_meter_installed_in_March_last_year_matches_from_March_on_both_sides()
{
var result = MatchYearToDate(
Now,
[Run(At(2025, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 3, 10), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 3, 10), result.Current!.From);
Assert.Equal(new DateOnly(2026, 3, 10), result.Current.FirstDay);
Assert.Equal(At(2025, 3, 10), result.Comparison!.From);
Assert.Equal(Now, result.Current.To);
}
[Fact]
public void An_outage_in_the_comparison_year_splits_the_match_and_is_left_out_of_both_sides()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[
Run(At(2025, 1, 1), At(2025, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2025, 3, 10, 14), At(2025, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2025, 3, 13, 9), At(2026, 1, 1), ResolutionClass.Hour),
]);
Assert.False(result.IsContiguous);
Assert.Equal(2, result.Pieces.Count);
Assert.Equal(At(2026, 3, 10, 14), result.Pieces[0].Current.To);
Assert.Equal(At(2026, 3, 13, 9), result.Pieces[1].Current.From);
// The gap's row is booked at its end, 13 March 09:00: the second piece starts just past it, and the
// first ends just past the last sample before the outage.
Assert.Equal(At(2025, 3, 10, 14) + Past, result.Pieces[0].Comparison.To);
Assert.Equal(At(2025, 3, 13, 9) + Past, result.Pieces[1].Comparison.From);
// The spans still run from the first to the last matched instant.
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(Now, result.Current.To);
}
// ---- Opening balances (A-01) ----------------------------------------------------------------------
[Fact]
public void An_opening_balance_at_the_start_of_the_current_data_stays_out_of_the_match()
{
// D-14: the first reading's row holds consumption of unknown extent.
var firstReading = At(2026, 2, 3, 11);
var result = MatchYearToDate(
Now,
[Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
currentOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Current!.From);
Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay);
Assert.Equal(At(2025, 2, 3, 11), result.Comparison!.From);
}
[Fact]
public void An_opening_balance_read_at_midnight_stays_out_of_a_match_starting_there()
{
// m2 #2: a first reading at 00:00 describes no time before it, so its row stays on the midnight
// (D-11 only moves rows that close an interval) and a range starting there must step past it.
var firstReading = At(2026, 2, 3);
var result = MatchYearToDate(
Now,
[Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
currentOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Current!.From);
Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay);
Assert.Equal(At(2025, 2, 3), result.Comparison!.From);
}
[Fact]
public void An_opening_balance_in_the_comparison_year_moves_the_start_of_both_sides()
{
var firstReading = At(2025, 4, 7, 15);
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(firstReading, At(2026, 1, 1), ResolutionClass.Hour)],
comparisonOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Comparison!.From);
Assert.Equal(At(2026, 4, 7, 15), result.Current!.From);
}
// ---- Not comparable ---------------------------------------------------------------------------------
[Fact]
public void No_coverage_in_the_comparison_period_is_not_comparable()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 10, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.False(result.IsComparable);
Assert.Same(MatchedCoverageResult.NotComparable, result);
Assert.Null(result.Current);
Assert.Null(result.Comparison);
}
[Fact]
public void No_coverage_in_the_current_period_is_not_comparable()
{
var result = MatchYearToDate(Now, [], [MonthLabels(2025, 1, 2026, 1)]);
Assert.False(result.IsComparable);
}
[Fact]
public void An_overlap_shorter_than_the_monthly_side_can_resolve_is_not_comparable()
{
// Hourly data for 1-20 January against a year of month rows: January cannot be cut on the 20th.
var now = At(2026, 1, 20, 12);
var result = MatchYearToDate(now, [Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour)], [MonthLabels(2025, 1, 2026, 1)]);
Assert.False(result.IsComparable);
}
// ---- Calendar shifts ------------------------------------------------------------------------------
[Fact]
public void A_previous_period_shifted_by_days_is_trimmed_on_the_side_whose_data_is_coarse()
{
// 10-19 September against 31 August - 9 September, with the earlier stretch read once a day at noon.
var now = At(2026, 9, 19, 10);
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 19), now);
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 9, 1), At(2026, 10, 1), ResolutionClass.Hour)]),
MatchSide.Of(At(2026, 8, 31), ShiftDays(now, -10), now, Berlin, [Run(At(2026, 8, 30, 12), At(2026, 9, 12, 12), ResolutionClass.Day, divided: true)]),
t => ShiftDays(t, -10));
// The hourly side needs no trimming; the daily side ends its match at local midnight on 9 September.
Assert.Equal(At(2026, 9, 10), result.Current!.From);
Assert.Equal(At(2026, 9, 19), result.Current.To);
Assert.Equal(At(2026, 8, 31), result.Comparison!.From);
Assert.Equal(At(2026, 9, 9), result.Comparison.To);
}
[Fact]
public void A_match_across_the_spring_DST_change_lines_up_on_local_wall_time()
{
// DST began on 30 March 2025 and 29 March 2026; the matched ranges still start and end at local midnights.
var now = At(2026, 4, 15, 12);
var result = MatchYearToDate(
now,
[Run(At(2026, 3, 1), At(2026, 4, 16, 6), ResolutionClass.Day, divided: true)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 3, 1), result.Current!.From);
Assert.Equal(At(2026, 4, 15), result.Current.To);
Assert.Equal(At(2025, 3, 1), result.Comparison!.From);
Assert.Equal(At(2025, 4, 15), result.Comparison.To);
Assert.Equal(new DateOnly(2025, 4, 14), result.Comparison.LastDay);
}
[Fact]
public void A_match_on_the_long_autumn_day_cuts_both_years_at_the_same_wall_time()
{
// 25 October 2026 has 25 hours in Berlin; on 25 October 2025 the clocks had not gone back yet.
var now = At(2026, 10, 25, 12);
var hourly = MatchYearToDate(
now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
var daily = MatchYearToDate(
now,
[Run(At(2025, 12, 31, 6), At(2026, 12, 1, 6), ResolutionClass.Day, divided: true)],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(now, hourly.Current!.To);
Assert.Equal(At(2025, 10, 25, 12), hourly.Comparison!.To);
Assert.Equal(TimeSpan.FromHours(1), (hourly.Current.To - hourly.Current.From) - (hourly.Comparison.To - hourly.Comparison.From));
Assert.Equal(At(2026, 10, 25), daily.Current!.To);
Assert.Equal(At(2025, 10, 25), daily.Comparison!.To);
Assert.Equal(new DateOnly(2025, 10, 24), daily.Comparison.LastDay);
}
[Fact]
public void March_to_date_on_the_30th_matches_all_of_February_because_the_29th_to_31st_map_onto_its_end()
{
// m2 #11: shifting by a month clamps 29-31 March to the end of February (D-06), so the shift is not
// one-to-one there: every instant from 29 March 00:00 maps to 1 March 00:00.
var now = At(2026, 3, 30, 10);
var current = Period(PeriodPreset.MonthToDate, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30), now);
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 1, 1), At(2026, 5, 1), ResolutionClass.Hour)]),
MatchSide.Of(At(2026, 2, 1), At(2026, 3, 1), now, Berlin, [Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Hour)]),
t => ShiftMonths(t, -1));
Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 29), -1));
Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 31, 23), -1));
Assert.Equal(new MatchedRange(At(2026, 3, 1), now, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30)), result.Current);
Assert.Equal(new MatchedRange(At(2026, 2, 1), At(2026, 3, 1), new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)), result.Comparison);
}
[Fact]
public void Year_to_date_on_29_February_matches_up_to_the_end_of_February_the_year_before()
{
// m2 #11: 29 February 2024 has no counterpart in 2023; the day maps onto 1 March 2023 00:00.
var now = At(2024, 2, 29, 10);
var result = MatchYearToDate(
now,
[Run(At(2023, 12, 31, 6), At(2024, 3, 10, 6), ResolutionClass.Day, divided: true)],
[Run(At(2022, 12, 1), At(2023, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(new MatchedRange(At(2024, 1, 1), At(2024, 2, 29), new DateOnly(2024, 1, 1), new DateOnly(2024, 2, 28)), result.Current);
Assert.Equal(new MatchedRange(At(2023, 1, 1), At(2023, 3, 1), new DateOnly(2023, 1, 1), new DateOnly(2023, 2, 28)), result.Comparison);
}
[Fact]
public void In_New_York_monthly_labels_match_on_New_York_month_starts()
{
var now = At(NewYork, 2026, 9, 19, 10);
var from = At(NewYork, 2026, 1, 1);
DateTimeOffset LastYear(DateTimeOffset t) => Shift(t, NewYork, months: -12);
var result = MatchedCoverage.Match(
MatchSide.Of(from, now, now, NewYork, [MonthLabels(NewYork, 2026, 1, 2026, 7)]),
MatchSide.Of(LastYear(from), LastYear(now), now, NewYork, [MonthLabels(NewYork, 2025, 1, 2026, 1)]),
LastYear);
Assert.Equal(new MatchedRange(At(NewYork, 2026, 1, 1), At(NewYork, 2026, 7, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 30)), result.Current);
Assert.Equal(new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero), result.Current!.To);
Assert.Equal(new MatchedRange(At(NewYork, 2025, 1, 1), At(NewYork, 2025, 7, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 6, 30)), result.Comparison);
}
}
@@ -0,0 +1,281 @@
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
public sealed class MeterRoleRulesTests
{
private const short Electricity = 1;
private const short Water = 2;
private static Meter MeterOf(
int id, string name, short energyTypeId, MeterMode mode, string? role = null, DateOnly? retiredAt = null) => new()
{
Id = id,
Name = name,
EnergyTypeId = energyTypeId,
Mode = mode,
Unit = "kWh",
Meta = MeterMeta.SetRole("{}", role),
RetiredAt = retiredAt,
};
[Theory]
[InlineData("total_load", MeterRole.TotalLoad)]
[InlineData("grid_import", MeterRole.GridImport)]
[InlineData("grid_export", MeterRole.GridExport)]
[InlineData(" GRID_IMPORT ", MeterRole.GridImport)]
public void A_stored_token_parses_to_its_role(string token, MeterRole role)
{
Assert.True(MeterRoleRules.TryParse(token, out var parsed));
Assert.Equal(role, parsed);
Assert.Equal(role, MeterRoleRules.Parse(token));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("pv")]
[InlineData("grid-import")]
public void Anything_else_is_no_role(string? token)
{
Assert.False(MeterRoleRules.TryParse(token, out _));
Assert.Null(MeterRoleRules.Parse(token));
}
[Fact]
public void Every_role_round_trips_through_its_stored_token()
{
foreach (var role in MeterRoleRules.All)
{
Assert.Equal(role, MeterRoleRules.Parse(MeterRoleRules.Token(role)));
}
Assert.Equal(MeterRoles.TotalLoad, MeterRoleRules.Token(MeterRole.TotalLoad));
Assert.Equal(MeterRoles.GridImport, MeterRoleRules.Token(MeterRole.GridImport));
Assert.Equal(MeterRoles.GridExport, MeterRoleRules.Token(MeterRole.GridExport));
}
[Theory]
[InlineData(MeterMode.CumulativeCounter)]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.InstantRate)]
public void Meters_that_measure_a_flow_may_hold_every_role(MeterMode mode)
{
Assert.Equal(MeterRoleRules.All, MeterRoleRules.AllowedFor(mode));
Assert.All(MeterRoleRules.All, role => Assert.True(MeterRoleRules.IsAllowed(role, mode)));
}
[Theory]
[InlineData(MeterMode.GenerationCounter)]
[InlineData(MeterMode.RuntimeCounter)]
[InlineData(MeterMode.ConsumableBalance)]
[InlineData(MeterMode.Virtual)]
public void Generation_runtime_tank_and_virtual_meters_hold_no_role(MeterMode mode)
{
Assert.Empty(MeterRoleRules.AllowedFor(mode));
Assert.All(MeterRoleRules.All, role => Assert.False(MeterRoleRules.IsAllowed(role, mode)));
}
[Fact]
public void The_effective_role_ignores_a_token_the_mode_cannot_hold()
{
Assert.Equal(MeterRole.GridImport, MeterRoleRules.Effective(MeterOf(1, "Netz", Electricity, MeterMode.CumulativeCounter, "grid_import")));
Assert.Null(MeterRoleRules.Effective(MeterOf(2, "Solar", Electricity, MeterMode.GenerationCounter, "grid_export")));
Assert.Null(MeterRoleRules.Effective(MeterOf(3, "Summe Solar", Electricity, MeterMode.Virtual, "total_load")));
Assert.Null(MeterRoleRules.Effective(MeterOf(4, "Auto", Electricity, MeterMode.CumulativeCounter)));
}
[Fact]
public void Assigning_a_held_role_names_the_meter_it_moves_from()
{
// The seeded installation: Haus holds total_load, Netz holds grid_import.
var meters = new[]
{
MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
MeterOf(3, "Zähler Auto", Electricity, MeterMode.CumulativeCounter),
};
var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 3);
Assert.NotNull(holder);
Assert.Equal("Zähler Haus", holder.Name);
}
[Fact]
public void A_free_role_has_no_holder()
{
var meters = new[]
{
MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
};
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 2));
}
[Fact]
public void Re_saving_the_holder_does_not_move_the_role_from_itself()
{
var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad) };
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 1));
}
[Fact]
public void Roles_are_unique_per_energy_type_not_globally()
{
var meters = new[]
{
MeterOf(1, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
MeterOf(2, "Hauswasser", Water, MeterMode.CumulativeCounter),
};
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Water, meterId: 2));
}
[Fact]
public void Legacy_duplicates_and_invalid_leftovers_all_give_the_role_up()
{
var meters = new[]
{
MeterOf(9, "Einspeisung alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport),
MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport),
MeterOf(7, "Einspeisung Garage", Electricity, MeterMode.DirectDelta, MeterRoles.GridExport),
MeterOf(5, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter),
MeterOf(6, "Wasser", Water, MeterMode.CumulativeCounter, MeterRoles.GridExport),
};
var holders = MeterRoleRules.Holders(meters, MeterRole.GridExport, Electricity, exceptMeterId: 5);
// Meters that really play the role first, then the leftover a generation counter cannot hold.
Assert.Equal([7, 9, 4], holders.Select(m => m.Id));
}
[Fact]
public void The_role_is_said_to_move_from_the_meter_that_played_it_not_from_an_invalid_leftover()
{
// Solar 1 (id 4) still stores a grid_export token it cannot hold; Einspeisung (id 9) is the export meter.
var meters = new[]
{
MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport),
MeterOf(9, "Einspeisung", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport),
MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter),
};
var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12);
Assert.NotNull(holder);
Assert.Equal("Einspeisung", holder.Name);
}
[Fact]
public void An_invalid_leftover_is_named_only_when_no_meter_played_the_role()
{
var meters = new[]
{
MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport),
MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter),
};
Assert.Equal(4, MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12)!.Id);
}
[Fact]
public void A_retired_meter_keeps_its_role_when_its_replacement_takes_it()
{
// A-07: the old grid meter measured the grid import of its own service period.
var meters = new[]
{
MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)),
MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter),
};
var replacement = MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport);
Assert.Empty(MeterRoleRules.Holders(meters, MeterRole.GridImport, Electricity, exceptMeterId: 15));
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Electricity, meterId: 15));
Assert.Empty(MeterRoleRules.Displaced(meters, replacement, MeterRole.GridImport));
}
[Fact]
public void A_retired_meter_saved_with_a_role_displaces_no_meter_in_service()
{
var meters = new[]
{
MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter),
};
var retired = MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30));
Assert.Empty(MeterRoleRules.Displaced(meters, retired, MeterRole.GridImport));
}
[Fact]
public void A_meter_in_service_saved_with_a_role_displaces_every_other_holder_in_service()
{
var meters = new[]
{
MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
MeterOf(3, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.TotalLoad),
MeterOf(8, "Haus alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad, new DateOnly(2020, 12, 31)),
MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
};
var taker = MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad);
var displaced = MeterRoleRules.Displaced(meters, taker, MeterRole.TotalLoad);
Assert.Equal([1, 3], displaced.Select(m => m.Id));
}
[Fact]
public void A_meter_whose_mode_cannot_hold_the_role_displaces_nobody()
{
var meters = new[] { MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport) };
var generation = MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridImport);
Assert.Empty(MeterRoleRules.Displaced(meters, generation, MeterRole.GridImport));
}
[Fact]
public void A_meter_counts_as_retired_once_its_lifecycle_end_is_recorded()
{
var retired = RoleCandidate.From(MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)));
var inService = RoleCandidate.From(MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, " GRID_IMPORT "));
Assert.Equal(new RoleCandidate(2, Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true), retired);
Assert.False(inService.IsRetired);
Assert.Equal(MeterRole.GridImport, inService.StoredRole);
Assert.Equal(MeterRole.GridImport, inService.EffectiveRole);
}
[Fact]
public void A_caller_decides_what_retired_means_by_building_the_candidates_itself()
{
// An analysis input that treats a deactivated meter as retired passes the flag directly.
var candidates = new[]
{
new RoleCandidate(2, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true),
new RoleCandidate(4, 1, MeterMode.GenerationCounter, MeterRoles.GridImport, IsRetired: false),
new RoleCandidate(15, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false),
new RoleCandidate(16, 2, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false),
};
var holders = MeterRoleRules.Holders(candidates, MeterRole.GridImport, energyTypeId: 1);
Assert.Equal([15, 4], holders.Select(c => c.MeterId));
Assert.Equal(15, MeterRoleRules.CurrentHolder(candidates, MeterRole.GridImport, 1, meterId: 30)!.MeterId);
Assert.Null(holders[1].EffectiveRole);
}
[Fact]
public void Malformed_meta_holds_no_role()
{
var broken = MeterOf(1, "Kaputt", Electricity, MeterMode.CumulativeCounter);
broken.Meta = "not json";
Assert.Empty(MeterRoleRules.Holders([broken], MeterRole.TotalLoad, Electricity));
Assert.Null(MeterRoleRules.Effective(broken));
}
}
@@ -0,0 +1,482 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
public sealed class NormalizedQuantityTests
{
private static readonly TankInfo OilTank = new("L", TankRateMode.Empirical, null);
private static Meter MeterOf(string name, MeterMode mode, string unit, string? role = null) => new()
{
Id = 1,
Name = name,
Mode = mode,
Unit = unit,
Meta = role is null ? "{}" : MeterMeta.SetRole("{}", role),
};
public static TheoryData<string, MeterMode, string, string?, QuantityKind, string> SeededMeters => new()
{
// The reference installation (ReferenceDataImporter), as D-22 classifies it.
{ "Zähler Haus", MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, QuantityKind.Consumption, "kWh" },
{ "Zähler Netz", MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, QuantityKind.Consumption, "kWh" },
{ "Zähler Auto", MeterMode.CumulativeCounter, "kWh", null, QuantityKind.Consumption, "kWh" },
{ "Zähler Solar 1", MeterMode.GenerationCounter, "kWh", null, QuantityKind.Generation, "kWh" },
{ "Zähler Solar 2", MeterMode.GenerationCounter, "kWh", null, QuantityKind.Generation, "kWh" },
{ "Zähler Wasser", MeterMode.CumulativeCounter, "m3", null, QuantityKind.Consumption, "m³" },
{ "Brenner", MeterMode.RuntimeCounter, "h", null, QuantityKind.Runtime, "h" },
};
[Theory]
[MemberData(nameof(SeededMeters))]
public void Seeded_meters_have_the_kind_and_unit_their_normalizer_books(
string name, MeterMode mode, string unit, string? role, QuantityKind kind, string normalizedUnit)
{
var quantity = NormalizedQuantity.Of(MeterOf(name, mode, unit, role));
Assert.Equal(new NormalizedQuantity(kind, normalizedUnit), quantity);
}
[Fact]
public void The_seeded_oil_tank_books_litres_of_consumption()
{
var tank = new Tank { MeterId = 1, Capacity = 7000, Unit = "L" };
var quantity = NormalizedQuantity.Of(MeterOf("Öltank", MeterMode.ConsumableBalance, "L"), tank);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L"), quantity);
}
[Fact]
public void Summe_Solar_is_the_generation_it_declares()
{
// D-28: the reference importer writes m(Solar 1) + m(Solar 2) as generation in kWh.
var quantity = NormalizedQuantity.Of(
MeterOf("Summe Solar", MeterMode.Virtual, "kWh"),
virtualResult: new DeclaredVirtualResult(QuantityKind.Generation, "kWh"));
Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity);
}
[Fact]
public void A_runtime_counter_without_a_tank_counts_hours()
{
var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, null, null);
Assert.Equal(QuantityKind.Runtime, quantity.Kind);
Assert.Equal("h", quantity.Unit);
Assert.Equal(QuantityNotes.None, quantity.Notes);
Assert.Equal(Provenance.None, quantity.ImpliedProvenance);
}
[Theory]
[InlineData("h", "h")]
[InlineData("Std", "h")]
[InlineData("Betriebsstunden", "h")]
[InlineData("min", "min")]
[InlineData("Minuten", "min")]
[InlineData("s", "s")]
public void A_runtime_counter_books_its_register_in_the_registers_own_time_unit(string meterUnit, string expected)
{
Assert.Equal(
new NormalizedQuantity(QuantityKind.Runtime, expected),
NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, null, null));
}
[Fact]
public void A_runtime_register_in_minutes_books_minutes_exactly_as_the_normalizer_does()
{
var context = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 21, Mode = MeterMode.RuntimeCounter, Unit = "min", InitialBaseline = 600 },
Readings = [Reading(21, Month(2023, 1), 600), Reading(21, Month(2023, 2), 720)],
};
var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount);
// 120 minutes of burner time, not 120 hours: an "EUR/h" price must see minutes, not hours.
Assert.Equal(120d, booked);
Assert.Equal("min", NormalizedQuantity.Of(MeterMode.RuntimeCounter, "min", null, null, null).Unit);
Assert.Equal(1 / 60d, TariffUnit.Applicability("EUR/h", "min", TariffComponent.UnitPrice).Factor, 12);
}
[Theory]
[InlineData("")]
[InlineData("Stk")]
[InlineData("kWh")]
public void A_runtime_counter_whose_register_names_no_time_counts_hours(string meterUnit)
{
Assert.Equal(
new NormalizedQuantity(QuantityKind.Runtime, "h"),
NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, null, null));
}
[Theory]
[InlineData("min")]
[InlineData("s")]
public void A_fixed_hourly_rate_on_a_register_that_does_not_count_hours_is_flagged(string meterUnit)
{
var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, new TankInfo("L", TankRateMode.Fixed, 2.0), null);
Assert.Equal("L", quantity.Unit);
Assert.Equal(QuantityNotes.FixedRateEstimate | QuantityNotes.RegisterNotInHours, quantity.Notes);
}
[Fact]
public void A_runtime_counter_with_a_fixed_rate_tank_books_the_tank_unit_as_an_estimate()
{
var tank = new TankInfo("Liter", TankRateMode.Fixed, 2.0);
var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null);
// D-20: the kind stays runtime; the volume is hours × nozzle rate, so it is estimated.
Assert.Equal(QuantityKind.Runtime, quantity.Kind);
Assert.Equal("L", quantity.Unit);
Assert.Equal(QuantityNotes.FixedRateEstimate, quantity.Notes);
Assert.Equal(Provenance.Estimated, quantity.ImpliedProvenance);
}
[Fact]
public void A_runtime_counter_with_an_empirical_tank_still_counts_hours()
{
var tank = new TankInfo("L", TankRateMode.Empirical, 2.0);
Assert.Equal(
new NormalizedQuantity(QuantityKind.Runtime, "h"),
NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null));
}
[Fact]
public void A_fixed_tank_without_a_rate_books_hours_exactly_as_the_normalizer_does()
{
var tank = new TankInfo("L", TankRateMode.Fixed, null);
var context = new NormalizationContext
{
Meter = new MeterConfig
{
MeterId = 20,
Mode = MeterMode.RuntimeCounter,
Unit = "h",
InitialBaseline = 100,
Tank = new TankConfig { Capacity = 7000, RateMode = TankRateMode.Fixed, FixedRate = null },
},
Readings = [Reading(20, Month(2023, 1), 100), Reading(20, Month(2023, 2), 167)],
};
var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount);
Assert.Equal(67d, booked); // hours, not litres
Assert.Equal("h", NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null).Unit);
}
[Fact]
public void A_fixed_rate_tank_without_a_unit_falls_back_to_litres_and_says_so()
{
var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, new TankInfo(" ", TankRateMode.Fixed, 1.8), null);
Assert.Equal("L", quantity.Unit);
Assert.Equal(QuantityNotes.FixedRateEstimate | QuantityNotes.NoTankUnit, quantity.Notes);
}
[Theory]
[InlineData("kW", "kWh")]
[InlineData("W", "Wh")]
[InlineData("MW", "MWh")]
[InlineData("mW", "mWh")]
[InlineData("W/m²", "Wh/m²")]
[InlineData("L/h", "L")]
[InlineData("m3/h", "m³")]
public void An_instant_rate_meter_books_its_rate_unit_integrated_over_hours(string rateUnit, string expected)
{
var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, expected), quantity);
}
[Fact]
public void An_instant_rate_in_kilowatts_integrates_to_the_kilowatt_hours_the_normalizer_books()
{
// 2 kW held for 90 minutes is 3 kWh.
var start = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero);
var context = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 7, Mode = MeterMode.InstantRate, Unit = "kW" },
Readings = [DayReading(7, start, 2), DayReading(7, start.AddMinutes(90), 2)],
};
var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount);
Assert.Equal(3d, booked, 9);
Assert.Equal("kWh", NormalizedQuantity.Of(MeterMode.InstantRate, "kW", null, null, null).Unit);
}
[Theory]
[InlineData("kWh", "kWh")]
[InlineData("Stk", "stk")]
public void An_instant_rate_in_a_unit_that_is_not_a_rate_is_flagged_as_assumed_per_hour(string rateUnit, string expected)
{
var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null);
Assert.Equal(expected, quantity.Unit);
Assert.Equal(QuantityNotes.RateAssumedPerHour, quantity.Notes);
}
[Theory]
[InlineData("L/min", "L/min")]
[InlineData("m³/s", "m³/s")]
[InlineData("l/Tag", "L/tag")]
public void An_instant_rate_over_another_time_keeps_its_rate_unit_so_no_price_per_quantity_applies(
string rateUnit, string expected)
{
var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, expected, QuantityNotes.RateNotPerHour), quantity);
Assert.Equal(TariffUnitFit.Mismatch, TariffUnit.Applicability("EUR/L", quantity.Unit, TariffComponent.UnitPrice).Fit);
Assert.Equal(TariffUnitFit.Mismatch, TariffUnit.Applicability("EUR/m³", quantity.Unit, TariffComponent.UnitPrice).Fit);
}
[Fact]
public void A_flow_per_minute_books_a_sixtieth_of_its_litres_so_calling_them_litres_would_be_wrong()
{
// 10 L/min held for an hour is 600 L; the normalizer books 10 because it integrates per hour.
var start = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero);
var context = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 8, Mode = MeterMode.InstantRate, Unit = "L/min" },
Readings = [DayReading(8, start, 10), DayReading(8, start.AddHours(1), 10)],
};
var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount);
Assert.Equal(10d, booked, 9);
Assert.NotEqual("L", NormalizedQuantity.Of(MeterMode.InstantRate, "L/min", null, null, null).Unit);
}
[Fact]
public void An_instant_rate_meter_without_a_unit_carries_no_note()
{
Assert.Equal(
new NormalizedQuantity(QuantityKind.Consumption, string.Empty),
NormalizedQuantity.Of(MeterMode.InstantRate, " ", null, null, null));
}
[Theory]
[InlineData(MeterMode.CumulativeCounter, "kWh", "kWh")]
[InlineData(MeterMode.DirectDelta, "kWh", "kWh")]
[InlineData(MeterMode.InstantRate, "kW", "kWh")]
public void A_grid_export_meter_measures_export_never_consumption(MeterMode mode, string unit, string expectedUnit)
{
var quantity = NormalizedQuantity.Of(mode, unit, MeterRoles.GridExport, null, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Export, expectedUnit), quantity);
}
[Theory]
[InlineData(MeterRoles.TotalLoad)]
[InlineData(MeterRoles.GridImport)]
[InlineData(null)]
[InlineData("pv_inverter")]
public void Other_roles_leave_a_counter_measuring_consumption(string? role)
{
var quantity = NormalizedQuantity.Of(MeterMode.CumulativeCounter, "kWh", role, null, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "kWh"), quantity);
}
[Fact]
public void The_role_is_read_from_the_meters_meta()
{
var export = MeterOf("Einspeisung", MeterMode.CumulativeCounter, "kWh", "GRID_EXPORT ");
Assert.Equal(QuantityKind.Export, NormalizedQuantity.Of(export).Kind);
}
[Fact]
public void A_role_the_mode_cannot_hold_is_ignored_and_flagged()
{
var generation = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", MeterRoles.GridExport, null, null);
var tank = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "L", MeterRoles.TotalLoad, OilTank, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh", QuantityNotes.RoleIgnored), generation);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L", QuantityNotes.RoleIgnored), tank);
}
[Fact]
public void A_tank_books_in_the_tank_unit_not_the_level_unit()
{
// Dipstick readings in cm are calibrated to the tank's litres.
var quantity = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "cm", null, new TankInfo("Liter", TankRateMode.Empirical, null), null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L"), quantity);
}
[Fact]
public void A_tank_meter_without_a_tank_falls_back_to_its_own_unit()
{
var quantity = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "Liter", null, null, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L", QuantityNotes.NoTankUnit), quantity);
}
[Theory]
[InlineData("m3", "m³")]
[InlineData("KWH", "kWh")]
[InlineData("Stk", "stk")]
public void A_direct_delta_meter_books_consumption_in_its_own_normalized_unit(string unit, string expected)
{
Assert.Equal(
new NormalizedQuantity(QuantityKind.Consumption, expected),
NormalizedQuantity.Of(MeterMode.DirectDelta, unit, null, null, null));
}
[Fact]
public void A_generation_counter_books_generation_in_its_own_unit()
{
Assert.Equal(
new NormalizedQuantity(QuantityKind.Generation, "MWh"),
NormalizedQuantity.Of(MeterMode.GenerationCounter, "MWH", null, null, null));
}
[Theory]
[InlineData(QuantityKind.Consumption, "m3", "m³")]
[InlineData(QuantityKind.Generation, "kWh", "kWh")]
[InlineData(QuantityKind.Net, "kWh", "kWh")]
[InlineData(QuantityKind.Indicator, "%", "%")]
public void A_virtual_meter_is_its_declared_result(QuantityKind kind, string unit, string expectedUnit)
{
var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, new DeclaredVirtualResult(kind, unit));
Assert.Equal(new NormalizedQuantity(kind, expectedUnit), quantity);
}
[Fact]
public void A_virtual_meter_without_a_declaration_assumes_consumption_in_its_unit_and_says_so()
{
var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, null);
Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "kWh", QuantityNotes.UndeclaredResult), quantity);
}
[Theory]
[InlineData(QuantityKind.Cost)]
[InlineData(QuantityKind.Export)]
[InlineData(QuantityKind.Runtime)]
public void A_virtual_meter_cannot_declare_a_kind_outside_D25(QuantityKind kind)
{
var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, new DeclaredVirtualResult(kind, "kWh"));
Assert.Equal(QuantityKind.Consumption, quantity.Kind);
Assert.Equal(QuantityNotes.UndeclaredResult, quantity.Notes);
}
[Fact]
public void A_virtual_declaration_without_a_unit_keeps_the_meters_unit()
{
var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kwh", null, null, new DeclaredVirtualResult(QuantityKind.Generation, null));
Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity);
}
[Fact]
public void A_virtual_meter_cannot_hold_a_role()
{
var quantity = NormalizedQuantity.Of(
MeterMode.Virtual, "kWh", MeterRoles.GridExport, null, new DeclaredVirtualResult(QuantityKind.Generation, "kWh"));
Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh", QuantityNotes.RoleIgnored), quantity);
}
[Fact]
public void Deconstruction_gives_kind_and_unit_as_D20_writes_it()
{
var (kind, unit) = NormalizedQuantity.Of(MeterMode.CumulativeCounter, "m3", null, null, null);
var (_, _, notes) = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, new TankInfo("L", TankRateMode.Fixed, 2), null);
Assert.Equal(QuantityKind.Consumption, kind);
Assert.Equal("m³", unit);
Assert.Equal(QuantityNotes.FixedRateEstimate, notes);
}
[Fact]
public void A_legacy_sum_of_generation_meters_is_the_generation_it_adds_up()
{
// Summe Solar before D-28 converts it: the implied sum of Solar 1 and Solar 2.
var solar = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", null, null, null);
var declared = DeclaredVirtualResult.FromSources([solar, solar with { Unit = "KWH" }]);
Assert.Equal(new DeclaredVirtualResult(QuantityKind.Generation, "kWh"), declared);
Assert.Equal(
new NormalizedQuantity(QuantityKind.Generation, "kWh"),
NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared));
}
[Fact]
public void A_legacy_sum_takes_the_canonical_unit_of_its_sources()
{
var declared = DeclaredVirtualResult.FromSources([QuantityKind.Consumption, QuantityKind.Consumption], ["m3", "m³"]);
Assert.Equal(new DeclaredVirtualResult(QuantityKind.Consumption, "m³"), declared);
}
public static TheoryData<QuantityKind[], string?[]> SumsThatNeedConfiguration => new()
{
{ [], [] },
{ [QuantityKind.Consumption, QuantityKind.Generation], ["kWh", "kWh"] },
{ [QuantityKind.Consumption, QuantityKind.Consumption], ["kWh", "m³"] },
{ [QuantityKind.Consumption, QuantityKind.Consumption], ["kWh", null] },
{ [QuantityKind.Indicator, QuantityKind.Indicator], ["%", "%"] },
{ [QuantityKind.Export], ["kWh"] },
{ [QuantityKind.Runtime, QuantityKind.Runtime], ["h", "h"] },
{ [QuantityKind.Cost], ["EUR"] },
};
[Theory]
[MemberData(nameof(SumsThatNeedConfiguration))]
public void A_legacy_sum_that_mixes_kinds_or_units_or_adds_what_no_virtual_meter_may_declare_needs_configuration(
QuantityKind[] kinds, string?[] units)
{
var declared = DeclaredVirtualResult.FromSources(kinds, units);
Assert.Null(declared);
Assert.Equal(
QuantityNotes.UndeclaredResult,
NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared).Notes);
}
[Fact]
public void A_legacy_sum_over_a_virtual_source_that_is_itself_undeclared_needs_configuration()
{
var solar = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", null, null, null);
var undeclared = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, null);
Assert.Null(DeclaredVirtualResult.FromSources([solar, undeclared]));
Assert.Null(DeclaredVirtualResult.FromSources([undeclared]));
}
[Fact]
public void A_legacy_sum_of_net_balances_stays_net()
{
Assert.Equal(
new DeclaredVirtualResult(QuantityKind.Net, "kWh"),
DeclaredVirtualResult.FromSources([QuantityKind.Net, QuantityKind.Net], ["kWh", "kwh"]));
}
[Fact]
public void A_legacy_sum_whose_sources_have_no_unit_keeps_the_meters_unit()
{
var declared = DeclaredVirtualResult.FromSources([QuantityKind.Consumption], [" "]);
Assert.Equal(new DeclaredVirtualResult(QuantityKind.Consumption, null), declared);
Assert.Equal("kWh", NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared).Unit);
}
[Fact]
public void Kinds_and_units_of_a_legacy_sum_must_describe_the_same_sources()
{
Assert.Throws<ArgumentException>(() => DeclaredVirtualResult.FromSources([QuantityKind.Consumption], ["kWh", "kWh"]));
}
}
@@ -0,0 +1,425 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A period resolves once, in the instance zone, into local dates for display and a half-open UTC range
/// for queries (D-02 – D-04). What these pin down: ranges start at local midnight, never UTC midnight;
/// "today" is the local date; to-date periods stop at the captured now; and every preset is computed from
/// frozen instants, so none of this depends on when the tests run.
/// </summary>
public sealed class PeriodResolverTests
{
private static ResolvedPeriod Resolve(PeriodPreset preset, DateTimeOffset now, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(preset, null, null, now, zone ?? Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset now, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now, zone ?? Berlin);
[Fact]
public void Month_to_date_in_Berlin_on_19_September_runs_from_local_midnight_on_the_1st_to_now()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Resolve(PeriodPreset.MonthToDate, now);
Assert.Equal(Day(2026, 9, 1), period.FirstDay);
Assert.Equal(Day(2026, 9, 19), period.LastDay);
Assert.Equal(Utc(2026, 8, 31, 22), period.From);
Assert.Equal(Utc(2026, 9, 19, 12, 37), period.To);
Assert.Equal(now, period.Now);
Assert.True(period.IsToDate);
Assert.False(period.ExtendsPastNow);
Assert.False(period.NotYetOccurred);
Assert.Same(Berlin, period.Zone);
// The named range is the whole month; comparisons and projections read that, the display reads the cut.
Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay());
Assert.Equal(Day(2026, 9, 30), period.NominalLastDay());
Assert.Equal(Utc(2026, 9, 30, 22), period.NominalEnd());
}
[Theory]
[InlineData(PeriodPreset.LastMonth, "2026-08-01", "2026-08-31", "2026-07-31T22:00Z", "2026-08-31T22:00Z", false)]
[InlineData(PeriodPreset.YearToDate, "2026-01-01", "2026-09-19", "2025-12-31T23:00Z", null, true)]
[InlineData(PeriodPreset.PreviousYear, "2025-01-01", "2025-12-31", "2024-12-31T23:00Z", "2025-12-31T23:00Z", false)]
[InlineData(PeriodPreset.Last12Months, "2025-10-01", "2026-09-19", "2025-09-30T22:00Z", null, true)]
[InlineData(PeriodPreset.Last24Months, "2024-10-01", "2026-09-19", "2024-09-30T22:00Z", null, true)]
public void Every_preset_on_19_September_resolves_to_its_local_calendar_range(
PeriodPreset preset, string first, string last, string from, string? to, bool toDate)
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Resolve(preset, now);
Assert.Equal(Iso(first), period.FirstDay);
Assert.Equal(Iso(last), period.LastDay);
Assert.Equal(IsoInstant(from), period.From);
Assert.Equal(to is null ? now : IsoInstant(to), period.To);
Assert.Equal(toDate, period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void The_last_12_months_are_twelve_calendar_months_ending_with_the_current_partial_one()
{
var period = Resolve(PeriodPreset.Last12Months, At(Berlin, 2026, 9, 19, 14, 37));
// Not 13 months, and no future month: October 2025 through September 2026, stopping now.
Assert.Equal(Day(2025, 10, 1), period.FirstDay);
Assert.Equal(Day(2026, 9, 30), period.NominalLastDay());
Assert.Equal(period.Now, period.To);
}
[Fact]
public void Half_an_hour_into_New_Year_the_current_month_and_year_are_2027_although_UTC_is_still_in_2026()
{
var now = At(Berlin, 2027, 1, 1, 0, 30);
Assert.Equal(Utc(2026, 12, 31, 23, 30), now.ToUniversalTime());
var month = Resolve(PeriodPreset.MonthToDate, now);
var year = Resolve(PeriodPreset.YearToDate, now);
var lastMonth = Resolve(PeriodPreset.LastMonth, now);
var previousYear = Resolve(PeriodPreset.PreviousYear, now);
var last12 = Resolve(PeriodPreset.Last12Months, now);
Assert.Equal((Day(2027, 1, 1), Day(2027, 1, 1)), (month.FirstDay, month.LastDay));
Assert.Equal((Utc(2026, 12, 31, 23), Utc(2026, 12, 31, 23, 30)), (month.From, month.To));
Assert.Equal((month.From, month.To), (year.From, year.To));
Assert.Equal(Day(2027, 12, 31), year.NominalLastDay());
Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 31)), (lastMonth.FirstDay, lastMonth.LastDay));
Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 12, 31, 23)), (lastMonth.From, lastMonth.To));
Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (previousYear.FirstDay, previousYear.LastDay));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 12, 31, 23)), (previousYear.From, previousYear.To));
Assert.Equal(Day(2026, 2, 1), last12.FirstDay);
Assert.Equal(Day(2027, 1, 1), last12.LastDay);
}
[Fact]
public void A_month_containing_the_spring_DST_change_is_one_hour_short_and_starts_in_winter_time()
{
var march = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 1, 9, 0));
Assert.Equal(Utc(2026, 2, 28, 23), march.From);
Assert.Equal(Utc(2026, 3, 31, 22), march.To);
Assert.Equal(TimeSpan.FromHours((31 * 24) - 1), march.To - march.From);
}
[Fact]
public void On_the_spring_DST_day_itself_month_to_date_runs_from_the_1st_in_winter_time_to_now_in_summer_time()
{
var now = At(Berlin, 2026, 3, 29, 10, 0);
Assert.Equal(TimeSpan.FromHours(2), now.Offset);
var period = Resolve(PeriodPreset.MonthToDate, now);
Assert.Equal(Utc(2026, 2, 28, 23), period.From);
Assert.Equal(Utc(2026, 3, 29, 8), period.To);
Assert.Equal(Day(2026, 3, 29), period.LastDay);
}
[Fact]
public void A_month_containing_the_autumn_DST_change_is_one_hour_long()
{
var october = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0));
Assert.Equal(Utc(2026, 9, 30, 22), october.From);
Assert.Equal(Utc(2026, 10, 31, 23), october.To);
Assert.Equal(TimeSpan.FromHours((31 * 24) + 1), october.To - october.From);
}
[Fact]
public void In_the_repeated_autumn_hour_today_is_still_the_DST_day_and_the_cut_is_the_exact_instant()
{
// 02:30 in winter time, the second time the clock shows 02:30 on 25 October 2026.
var now = At(2026, 10, 25, 2, 30, offsetHours: 1);
var period = Resolve(PeriodPreset.MonthToDate, now);
Assert.Equal(Day(2026, 10, 25), period.LastDay);
Assert.Equal(Utc(2026, 10, 25, 1, 30), period.To);
}
[Fact]
public void Behind_UTC_the_local_date_decides_the_month_even_when_UTC_has_moved_on()
{
// 21:00 on 30 September in New York is already 1 October in UTC.
var now = At(NewYork, 2026, 9, 30, 21, 0);
Assert.Equal(Utc(2026, 10, 1, 1), now.ToUniversalTime());
var period = Resolve(PeriodPreset.MonthToDate, now, NewYork);
Assert.Equal(Day(2026, 9, 1), period.FirstDay);
Assert.Equal(Day(2026, 9, 30), period.LastDay);
Assert.Equal(Utc(2026, 9, 1, 4), period.From);
Assert.Equal(Utc(2026, 10, 1, 1), period.To);
}
[Fact]
public void Behind_UTC_a_month_ending_in_winter_time_spans_its_local_midnights()
{
// November 2026 starts in daylight time (EDT, -4) and ends in standard time (EST, -5).
var november = Resolve(PeriodPreset.LastMonth, At(NewYork, 2026, 12, 1, 0, 30), NewYork);
Assert.Equal(Utc(2026, 11, 1, 4), november.From);
Assert.Equal(Utc(2026, 12, 1, 5), november.To);
}
[Fact]
public void Every_instant_is_stored_in_UTC_whatever_offset_now_arrives_with()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Resolve(PeriodPreset.YearToDate, now);
Assert.Equal(TimeSpan.Zero, period.From.Offset);
Assert.Equal(TimeSpan.Zero, period.To.Offset);
Assert.Equal(TimeSpan.Zero, period.Now.Offset);
}
[Fact]
public void A_custom_range_in_the_past_is_complete_and_ends_at_the_local_midnight_after_its_last_day()
{
var period = Custom(Day(2026, 6, 1), Day(2026, 6, 30), At(Berlin, 2026, 9, 19, 14, 37));
Assert.Equal(Utc(2026, 5, 31, 22), period.From);
Assert.Equal(Utc(2026, 6, 30, 22), period.To);
Assert.False(period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void A_custom_range_reaching_past_now_is_capped_at_now_and_keeps_the_requested_dates()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31), now);
Assert.Equal(Day(2026, 9, 1), period.FirstDay);
Assert.Equal(Day(2026, 12, 31), period.LastDay);
Assert.Equal(Utc(2026, 8, 31, 22), period.From);
Assert.Equal(now, period.To);
Assert.True(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.False(period.NotYetOccurred);
Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay());
// Rows recorded after now are looked for up to the end of what was asked.
Assert.Equal(Utc(2026, 12, 31, 23), period.NominalEnd());
}
[Fact]
public void A_custom_range_ending_today_is_cut_at_now_but_does_not_extend_past_it()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Custom(Day(2026, 9, 10), Day(2026, 9, 19), now);
Assert.Equal(now, period.To);
Assert.True(period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void A_custom_range_entirely_in_the_future_has_not_occurred_and_an_empty_query_range()
{
var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37));
Assert.True(period.NotYetOccurred);
Assert.Equal(Day(2027, 1, 1), period.FirstDay);
Assert.Equal(Day(2027, 3, 31), period.LastDay);
Assert.Equal(Utc(2026, 12, 31, 23), period.From);
// Nothing can be counted as an actual: [From, To) is empty.
Assert.Equal(period.From, period.To);
Assert.False(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.False(period.HasNoHistory());
Assert.True(period.HasNotStarted());
}
[Fact]
public void A_range_that_has_not_started_has_no_effective_days_so_no_rollup_day_is_summed_as_an_actual()
{
// Summing daily rollups from FirstDay to EffectiveLastDay must not reach into 2027: every row there
// is recorded after now (D-04).
var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37));
Assert.Equal(Day(2026, 12, 31), period.EffectiveLastDay());
Assert.True(period.EffectiveLastDay() < period.FirstDay);
}
[Fact]
public void The_effective_last_day_is_today_for_a_to_date_period_and_the_last_day_for_a_complete_one()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
Assert.Equal(Day(2026, 9, 19), Custom(Day(2026, 9, 1), Day(2026, 12, 31), now).EffectiveLastDay());
Assert.Equal(Day(2026, 8, 31), Resolve(PeriodPreset.LastMonth, now).EffectiveLastDay());
Assert.Equal(Day(2026, 9, 18), PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin).EffectiveLastDay());
}
[Fact]
public void At_the_exact_local_midnight_that_starts_a_month_its_month_to_date_has_started_with_nothing_elapsed()
{
var now = At(Berlin, 2026, 10, 1);
var period = Resolve(PeriodPreset.MonthToDate, now);
// Like the current month of "last 12 months" at the same instant: today exists, and is empty.
Assert.Equal((period.From, period.From), (period.To, period.Now));
Assert.True(period.IsToDate);
Assert.False(period.HasNotStarted());
Assert.Equal((Day(2026, 10, 1), Day(2026, 10, 1)), (period.FirstDay, period.LastDay));
Assert.Equal(Day(2026, 10, 1), period.EffectiveLastDay());
}
[Fact]
public void At_the_exact_local_midnight_no_history_is_neither_started_nor_waiting_to_start()
{
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 10, 1), Berlin);
Assert.True(period.HasNoHistory());
Assert.False(period.HasNotStarted());
}
[Theory]
[InlineData(null, "2026-09-30")]
[InlineData("2026-10-01", null)]
[InlineData("2026-09-30", "2026-09-01")]
[InlineData("1899-12-31", "2026-01-01")]
[InlineData("2026-01-01", "2300-01-01")]
public void A_custom_range_that_is_missing_reversed_or_out_of_bounds_is_rejected(string? first, string? last)
{
DateOnly? from = first is null ? null : Iso(first);
DateOnly? to = last is null ? null : Iso(last);
Assert.False(PeriodResolver.IsValidCustomRange(from, to));
Assert.Throws<ArgumentException>(() =>
PeriodResolver.Resolve(PeriodPreset.Custom, from, to, At(Berlin, 2026, 9, 19, 14, 37), Berlin));
}
[Fact]
public void All_history_spans_the_available_data_and_stops_at_its_last_day_when_that_is_in_the_past()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31));
Assert.Equal(Day(1997, 1, 1), period.FirstDay);
Assert.Equal(Day(2026, 5, 31), period.LastDay);
Assert.Equal(Utc(1996, 12, 31, 23), period.From);
Assert.Equal(Utc(2026, 5, 31, 22), period.To);
Assert.False(period.IsToDate);
}
[Fact]
public void All_history_of_live_data_runs_to_now()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var open = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1));
var toToday = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2026, 9, 19));
Assert.Equal(now, open.To);
Assert.True(open.IsToDate);
Assert.Equal(Day(2026, 9, 19), open.LastDay);
Assert.Equal(open, toToday);
}
[Fact]
public void All_history_without_any_available_data_is_an_explicit_no_history_result()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin);
Assert.True(period.HasNoHistory());
Assert.Equal(PeriodPreset.AllHistory, period.Preset);
Assert.Equal(Day(2026, 9, 19), period.FirstDay);
Assert.True(period.LastDay < period.FirstDay);
Assert.Equal(period.From, period.To);
Assert.Equal(Utc(2026, 9, 18, 22), period.From);
// "No history" is not "not yet occurred", and nothing is to date.
Assert.False(period.NotYetOccurred);
Assert.False(period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void Availability_whose_last_day_precedes_its_first_is_treated_as_no_history()
{
var period = PeriodResolver.Resolve(
PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 9, 19, 14, 37), Berlin, Day(2026, 5, 1), Day(2026, 4, 30));
Assert.True(period.HasNoHistory());
}
[Theory]
[InlineData("0001-01-01")]
[InlineData("0206-05-01")]
[InlineData("1899-12-31")]
public void All_history_reads_a_stray_reading_date_before_1900_as_1900_instead_of_throwing(string strayFirst)
{
// Availability comes from the data, not from a validated URL token: year 1 used to throw from the
// local-midnight arithmetic in Berlin, and year 206 spanned 1,821 years.
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Iso(strayFirst));
Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay);
Assert.Equal(Day(2026, 9, 19), period.LastDay);
Assert.Equal(now, period.To);
}
[Fact]
public void All_history_reads_availability_after_today_as_today_because_actuals_stop_at_now()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var future = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2027, 3, 1));
var onlyFuture = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2027, 1, 1));
Assert.Equal(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)), future);
Assert.True(future.IsToDate);
Assert.False(future.ExtendsPastNow);
Assert.True(onlyFuture.HasNoHistory());
}
[Fact]
public void All_history_is_open_ended_so_rows_recorded_far_after_now_are_still_looked_for()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var live = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1));
var historical = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31));
// A Tasmota row stamped in 2027 must reach the "recorded after now" block (D-04): no upper bound.
Assert.Null(live.NominalEnd());
Assert.Null(historical.NominalEnd());
Assert.Equal(Day(2026, 9, 19), live.NominalLastDay());
// Every other preset names where it ends.
Assert.Equal(Utc(2026, 12, 31, 23), Resolve(PeriodPreset.YearToDate, now).NominalEnd());
Assert.Equal(Utc(2026, 8, 31, 22), Resolve(PeriodPreset.LastMonth, now).NominalEnd());
}
[Fact]
public void Presets_ignore_custom_dates_and_custom_ignores_availability()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var month = PeriodPreset.MonthToDate;
Assert.Equal(
PeriodResolver.Resolve(month, null, null, now, Berlin),
PeriodResolver.Resolve(month, Day(2020, 1, 1), Day(2020, 1, 31), now, Berlin, Day(1997, 1, 1)));
var custom = PeriodResolver.Resolve(PeriodPreset.Custom, Day(2026, 6, 1), Day(2026, 6, 30), now, Berlin, Day(1997, 1, 1), Day(1998, 1, 1));
Assert.Equal(Day(2026, 6, 1), custom.FirstDay);
}
}
@@ -0,0 +1,62 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Provenance is its own dimension (D-14): which qualities a bucket's amount rests on, whatever its status.
/// </summary>
public sealed class ProvenanceRulesTests
{
[Fact]
public void Each_quality_with_an_amount_sets_its_own_flag()
{
Assert.Equal(Provenance.Measured, ProvenanceRules.ProvenanceOf(1, 0, 0, 0, false, false));
Assert.Equal(Provenance.Manual, ProvenanceRules.ProvenanceOf(0, 2, 0, 0, false, false));
Assert.Equal(Provenance.Imported, ProvenanceRules.ProvenanceOf(0, 0, 3, 0, false, false));
Assert.Equal(Provenance.Estimated, ProvenanceRules.ProvenanceOf(0, 0, 0, 4, false, false));
}
[Fact]
public void A_bucket_mixing_imported_months_and_divided_shares_is_both_imported_and_estimated()
{
var provenance = ProvenanceRules.ProvenanceOf(0, 0, 14, 2.5, false, false);
Assert.Equal(Provenance.Imported | Provenance.Estimated, provenance);
}
[Fact]
public void A_negative_amount_is_still_a_contribution_because_savings_and_balances_are_signed()
{
Assert.Equal(Provenance.Measured, ProvenanceRules.ProvenanceOf(-5, 0, 0, 0, false, false));
}
[Fact]
public void Float_noise_left_by_dividing_and_resumming_does_not_count_as_a_contribution()
{
Assert.Equal(Provenance.Manual, ProvenanceRules.ProvenanceOf(1e-12, 7, -1e-10, 0, false, false));
}
[Fact]
public void Opening_balance_and_derived_are_flags_of_their_own()
{
Assert.Equal(
Provenance.Measured | Provenance.OpeningBalance | Provenance.Derived,
ProvenanceRules.ProvenanceOf(3, 0, 0, 0, openingBalance: true, derived: true));
}
[Fact]
public void A_true_zero_has_no_provenance()
{
Assert.Equal(Provenance.None, ProvenanceRules.ProvenanceOf(0, 0, 0, 0, false, false));
}
[Fact]
public void A_derived_value_keeps_everything_its_sources_rest_on()
{
var derived = ProvenanceRules.Derive([Provenance.Measured, Provenance.Imported | Provenance.Estimated]);
Assert.Equal(Provenance.Derived | Provenance.Measured | Provenance.Imported | Provenance.Estimated, derived);
Assert.Equal(Provenance.Derived, ProvenanceRules.Derive([]));
}
}
@@ -0,0 +1,219 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Totals;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The pure pieces the Infrastructure reader rests on: a period as one bucket, lifecycle zeros (D-24), available
/// ranges (D-19) and the sum of a measure's members (D-22).
/// </summary>
public sealed class ReaderSupportTests
{
private static readonly DateTimeOffset Now = At(2026, 9, 19, 14, 37);
[Theory]
[InlineData(2025, 1, 1, 2025, 12, 31, BucketSize.Year)]
[InlineData(2024, 1, 1, 2025, 12, 31, BucketSize.Year)]
[InlineData(2026, 2, 1, 2026, 2, 28, BucketSize.Month)]
[InlineData(2025, 10, 1, 2026, 9, 30, BucketSize.Month)]
[InlineData(2026, 9, 14, 2026, 9, 20, BucketSize.Week)]
[InlineData(2026, 9, 15, 2026, 9, 21, BucketSize.Day)]
[InlineData(2026, 1, 15, 2026, 3, 31, BucketSize.Day)]
public void A_period_is_the_coarsest_unit_it_is_made_of(int y1, int m1, int d1, int y2, int m2, int d2, BucketSize expected) =>
Assert.Equal(expected, PeriodBucket.AlignedSize(new DateOnly(y1, m1, d1), new DateOnly(y2, m2, d2)));
[Fact]
public void A_year_to_date_is_a_year_cut_at_now()
{
var period = PeriodResolver.Resolve(PeriodPreset.YearToDate, null, null, Now, Berlin);
var bucket = PeriodBucket.Of(period);
Assert.Equal(BucketSize.Year, bucket.Size);
Assert.Equal(new DateOnly(2026, 1, 1), bucket.FirstDay);
Assert.Equal(new DateOnly(2026, 9, 20), bucket.EndDay);
Assert.Equal(period.From, bucket.From);
Assert.Equal(Now, bucket.To);
Assert.Equal(new DateOnly(2027, 1, 1), bucket.NominalEndDay);
}
[Fact]
public void A_monthly_source_resolves_the_month_as_a_period_although_not_its_days()
{
// The reason totals get their own status: last month by day is unresolved day by day, resolved as a month.
var period = PeriodResolver.Resolve(PeriodPreset.LastMonth, null, null, Now, Berlin);
var run = new CoverageRun(Midnight(new DateOnly(2026, 8, 1)), Midnight(new DateOnly(2026, 9, 1)), ResolutionClass.Month, DividedAtMonths: true);
var total = CoverageEvaluator.Evaluate(PeriodBucket.Of(period), [run], Berlin, openingBalanceInBucket: false, Now);
var day = CoverageEvaluator.Evaluate(Day(2026, 8, 10), [run], Berlin, openingBalanceInBucket: false, Now);
Assert.Equal(BucketStatus.Available, total.Status);
Assert.Equal(BucketStatus.Unresolved, day.Status);
}
[Fact]
public void A_period_that_has_not_started_is_an_empty_bucket()
{
var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), Now, Berlin);
var bucket = PeriodBucket.Of(period);
Assert.Equal(bucket.From, bucket.To);
Assert.Equal(bucket.FirstDay, bucket.EndDay);
}
[Fact]
public void Time_outside_the_service_period_is_known_zero_coverage()
{
var runs = LifecycleCoverage.OutsideService(new DateOnly(2026, 2, 10), new DateOnly(2026, 6, 30), Berlin);
Assert.Equal(2, runs.Count);
Assert.Equal(Midnight(new DateOnly(2026, 2, 10)), runs[0].To);
Assert.Equal(Midnight(new DateOnly(2026, 7, 1)), runs[1].From);
Assert.All(runs, r => Assert.False(r.IsGap));
Assert.All(runs, r => Assert.Equal(ResolutionClass.Hour, r.Resolution));
Assert.Empty(LifecycleCoverage.OutsideService(null, null, Berlin));
}
[Fact]
public void A_meter_installed_mid_month_contributes_a_whole_month()
{
// Installed 10 February with daily data from then: as its own series February is partial, as a member of a
// total the days before installation are a known zero and February is complete (D-24).
var data = new CoverageRun(Midnight(new DateOnly(2026, 2, 10)), Midnight(new DateOnly(2026, 3, 1)), ResolutionClass.Day, DividedAtMonths: true);
var february = Month(2026, 2);
var own = CoverageEvaluator.Evaluate(february, [data], Berlin, false, Now);
var contributing = CoverageEvaluator.Evaluate(
february, LifecycleCoverage.WithService([data], new DateOnly(2026, 2, 10), null, Berlin), Berlin, false, Now);
Assert.Equal(BucketStatus.Partial, own.Status);
Assert.Equal(BucketStatus.Available, contributing.Status);
}
[Fact]
public void A_retired_meters_zero_is_known_only_up_to_now()
{
var runs = LifecycleCoverage.OutsideService(null, new DateOnly(2026, 6, 30), Berlin);
var capped = CoverageRuns.CapAt(runs, Now, Berlin);
Assert.Equal(Now, Assert.Single(capped).To);
}
[Fact]
public void Available_range_is_capped_at_now_and_names_its_latest_month()
{
// A current-month label row covers September but closes after now: availability ends where it starts.
var runs = new[]
{
new CoverageRun(Midnight(new DateOnly(2025, 11, 1)), Midnight(new DateOnly(2026, 10, 1)), ResolutionClass.Month, true,
LastIntervalStart: Midnight(new DateOnly(2026, 9, 1))),
};
var range = AvailableRange.OfRuns(runs, Now, Berlin);
Assert.NotNull(range);
Assert.Equal(new DateOnly(2025, 11, 1), range.FirstDay);
Assert.Equal(new DateOnly(2026, 8, 31), range.LastDay);
Assert.Equal(new DateOnly(2026, 8, 1), range.LatestMonth);
Assert.Null(AvailableRange.OfRuns([], Now, Berlin));
}
[Fact]
public void Available_ranges_unite_to_their_outer_bounds()
{
var early = AvailableRange.Of(Midnight(new DateOnly(2022, 11, 1)), Midnight(new DateOnly(2023, 1, 1)), Berlin);
var late = AvailableRange.Of(Midnight(new DateOnly(2026, 5, 1)), Midnight(new DateOnly(2026, 6, 1)), Berlin);
var union = AvailableRange.Union([early, null, late], Berlin);
Assert.NotNull(union);
Assert.Equal(new DateOnly(2022, 11, 1), union.FirstDay);
Assert.Equal(new DateOnly(2026, 5, 31), union.LastDay);
Assert.Equal(new DateOnly(2026, 5, 1), union.LatestMonth);
Assert.Null(AvailableRange.Union([null], Berlin));
}
[Fact]
public void Measure_members_add_up_when_all_are_available()
{
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Available(150, Provenance.Imported))]);
Assert.Equal(BucketStatus.Available, sum.Status);
Assert.Equal(250, sum.Value);
Assert.Equal(Provenance.Measured | Provenance.Imported, sum.Provenance);
}
[Fact]
public void A_missing_member_makes_the_total_partial_and_names_it()
{
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Missing())]);
Assert.Equal(BucketStatus.Partial, sum.Status);
Assert.Equal(100, sum.Value);
Assert.Equal(ValueIssue.MissingSource, sum.Issue);
Assert.Equal([2], sum.DependencyPath);
}
[Fact]
public void Without_any_member_data_the_total_is_missing()
{
var sum = MeasureValues.Sum([(1, BucketValue.Missing()), (2, BucketValue.Missing())]);
Assert.Equal(BucketStatus.Missing, sum.Status);
Assert.Null(sum.Value);
}
[Theory]
[InlineData(BucketStatus.Pending, ValueIssue.AnalysisPending)]
[InlineData(BucketStatus.Invalid, ValueIssue.NonFinite)]
[InlineData(BucketStatus.Unresolved, ValueIssue.CoarseResolution)]
public void A_spoiling_member_decides_the_total(BucketStatus status, ValueIssue issue)
{
var spoiling = new BucketValue(null, status, Provenance.None, issue, null, [7, 3]);
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (7, spoiling), (2, BucketValue.Missing())]);
Assert.Equal(status, sum.Status);
Assert.Null(sum.Value);
Assert.Equal(issue, sum.Issue);
Assert.Equal([7, 3], sum.DependencyPath);
}
[Fact]
public void A_partial_member_keeps_its_issue_and_signed_values_stay_signed()
{
var partial = new BucketValue(-40, BucketStatus.Partial, Provenance.Derived, ValueIssue.OpeningBalance);
var sum = MeasureValues.Sum([(1, BucketValue.Available(-10, Provenance.Measured)), (5, partial)]);
Assert.Equal(BucketStatus.Partial, sum.Status);
Assert.Equal(-50, sum.Value);
Assert.Equal(ValueIssue.OpeningBalance, sum.Issue);
Assert.Equal([5], sum.DependencyPath);
}
[Fact]
public void A_series_of_members_is_summed_bucket_by_bucket()
{
IReadOnlyList<BucketValue> a = [BucketValue.Available(100, Provenance.Measured), BucketValue.Available(80, Provenance.Measured)];
IReadOnlyList<BucketValue> b = [BucketValue.Available(150, Provenance.Measured), BucketValue.Missing()];
var series = MeasureValues.SumSeries([(1, a), (2, b)], 2);
Assert.Equal(250, series[0].Value);
Assert.Equal(BucketStatus.Available, series[0].Status);
Assert.Equal(80, series[1].Value);
Assert.Equal(BucketStatus.Partial, series[1].Status);
Assert.Throws<ArgumentException>(() => MeasureValues.SumSeries([(1, a)], 3));
}
private static AnalysisBucket Month(int year, int month)
{
var first = new DateOnly(year, month, 1);
return new AnalysisBucket(first, first.AddMonths(1), Midnight(first), Midnight(first.AddMonths(1)), BucketSize.Month);
}
}
@@ -0,0 +1,101 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The resolution classes of D-13, at their exact limits: an hour and a minute, 25 hours, seven days and
/// an hour, 31 days and two hours. The slack is what keeps real intervals — a late poll, a DST day, a
/// month with a DST hour — in the class they belong to. There is one classifier (A-09); the bucket side of
/// the same scale — which class a bucket size needs — is pinned here too.
/// </summary>
public sealed class ResolutionClassifierTests
{
public static TheoryData<TimeSpan, ResolutionClass> Limits => new()
{
{ TimeSpan.Zero, ResolutionClass.Hour },
{ TimeSpan.FromMinutes(5), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(60), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(61), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(61) + TimeSpan.FromTicks(1), ResolutionClass.Day },
{ TimeSpan.FromHours(23), ResolutionClass.Day },
{ TimeSpan.FromHours(25), ResolutionClass.Day },
{ TimeSpan.FromHours(25) + TimeSpan.FromSeconds(1), ResolutionClass.Week },
{ TimeSpan.FromDays(7) + TimeSpan.FromHours(1), ResolutionClass.Week },
{ TimeSpan.FromDays(7) + TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(1), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(2), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(2) + TimeSpan.FromSeconds(1), ResolutionClass.Coarse },
{ TimeSpan.FromDays(365 * 12), ResolutionClass.Coarse },
};
[Theory]
[MemberData(nameof(Limits))]
public void An_interval_is_classified_by_its_length_up_to_each_limit_inclusive(TimeSpan length, ResolutionClass expected)
{
Assert.Equal(expected, ResolutionClassifier.Classify(length));
}
[Fact]
public void A_negative_length_claims_no_time_and_is_the_finest_class()
{
Assert.Equal(ResolutionClass.Hour, ResolutionClassifier.Classify(TimeSpan.FromHours(-3)));
}
[Fact]
public void The_long_autumn_day_is_still_a_day_and_october_with_its_extra_hour_still_a_month()
{
Assert.Equal(TimeSpan.FromHours(25), InBerlin(2026, 10, 26) - InBerlin(2026, 10, 25));
Assert.Equal(ResolutionClass.Day, ResolutionClassifier.Classify(InBerlin(2026, 10, 25), InBerlin(2026, 10, 26)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 10, 1), InBerlin(2026, 11, 1)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2027, 2, 1), InBerlin(2027, 3, 1)));
}
[Fact]
public void A_week_read_an_hour_late_is_still_a_week_but_two_weeks_are_not()
{
Assert.Equal(ResolutionClass.Week, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 11)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 21, 10)));
}
[Theory]
[InlineData(ResolutionClass.Hour, 61)]
[InlineData(ResolutionClass.Day, 25 * 60)]
[InlineData(ResolutionClass.Week, (7 * 24 * 60) + 60)]
[InlineData(ResolutionClass.Month, (31 * 24 * 60) + 120)]
public void Each_class_admits_intervals_up_to_its_own_limit(ResolutionClass resolution, int minutes)
{
Assert.Equal(TimeSpan.FromMinutes(minutes), ResolutionClassifier.LimitOf(resolution));
Assert.Equal(resolution, ResolutionClassifier.Classify(ResolutionClassifier.LimitOf(resolution)));
}
[Fact]
public void Data_coarser_than_a_month_has_no_limit()
{
Assert.Equal(TimeSpan.MaxValue, ResolutionClassifier.LimitOf(ResolutionClass.Coarse));
}
[Theory]
[InlineData(BucketSize.Day, ResolutionClass.Day)]
[InlineData(BucketSize.Week, ResolutionClass.Week)]
[InlineData(BucketSize.Month, ResolutionClass.Month)]
[InlineData(BucketSize.Year, ResolutionClass.Month)]
public void A_bucket_size_names_the_coarsest_class_that_resolves_it_outright(BucketSize size, ResolutionClass expected)
{
Assert.Equal(expected, ResolutionClassifier.CoarsestResolving(size));
}
[Fact]
public void Auto_has_no_class_because_it_is_resolved_before_any_bucket_exists()
{
Assert.Throws<ArgumentOutOfRangeException>(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto));
}
[Fact]
public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value()
{
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Coarsest(ResolutionClass.Hour, ResolutionClass.Month));
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.Coarsest(ResolutionClass.Coarse, ResolutionClass.Day));
}
}
@@ -0,0 +1,278 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Rollups;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Day and month rollups (D-12): the buckets analysis reads instead of consumption. A rollup must sum exactly the
/// rows filed in its local day or month, keep the amount's provenance by quality, and carry the markers the
/// coverage evaluator and the recorded-after-now check need (opening balance, divided, latest interval end).
/// </summary>
public sealed class RollupBuilderTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Consumption Row(
DateTimeOffset time,
double amount,
ReadingQuality quality = ReadingQuality.Measured,
ConsumptionKind kind = ConsumptionKind.Consumption,
DateTimeOffset? intervalEnd = null,
bool divided = false,
bool openingBalance = false) => new()
{
MeterId = 1,
Time = time.ToUniversalTime(),
Amount = amount,
Quality = quality,
Kind = kind,
IntervalEnd = intervalEnd?.ToUniversalTime(),
Divided = divided,
OpeningBalance = openingBalance,
};
private IReadOnlyList<Consumption> Normalize(
MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, DateOnly? installedAt = null) =>
_engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt },
Readings = readings,
Events = [],
TimeZone = zone,
});
private static Reading Measured(DateTimeOffset time, double value) =>
new() { MeterId = 1, Time = time.ToUniversalTime(), Value = value, Quality = ReadingQuality.Measured };
[Fact]
public void No_rows_give_no_buckets()
{
var rollups = RollupBuilder.Build([], Berlin);
Assert.Empty(rollups.Days);
Assert.Empty(rollups.Months);
}
[Fact]
public void A_row_is_filed_under_the_local_day_and_month_of_its_stamp()
{
// 23:30 UTC on 31 January is 00:30 on 1 February in Berlin: February, not January.
var rollups = RollupBuilder.Build([Row(Utc(2026, 1, 31, 23, 30), 5), Row(Utc(2026, 1, 31, 22, 30), 3)], Berlin);
Assert.Equal([new DateOnly(2026, 1, 31), new DateOnly(2026, 2, 1)], rollups.Days.Select(d => d.Start));
Assert.Equal([3d, 5d], rollups.Days.Select(d => d.Amount));
Assert.Equal([new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 1)], rollups.Months.Select(m => m.Start));
Assert.Equal([3d, 5d], rollups.Months.Select(m => m.Amount));
}
[Fact]
public void The_same_rows_land_in_other_days_in_another_zone()
{
var rows = new[] { Row(Utc(2026, 1, 31, 23, 30), 5) };
Assert.Equal(new DateOnly(2026, 1, 31), Assert.Single(RollupBuilder.Build(rows, TimeZoneInfo.Utc).Days).Start);
Assert.Equal(new DateOnly(2026, 1, 31), Assert.Single(RollupBuilder.Build(rows, NewYork).Days).Start);
Assert.Equal(new DateOnly(2026, 2, 1), Assert.Single(RollupBuilder.Build(rows, Berlin).Days).Start);
}
[Fact]
public void Amounts_are_split_by_quality_and_estimated_takes_interpolated_too()
{
var day = InBerlin(2026, 3, 10, 12);
var rollups = RollupBuilder.Build(
[
Row(day, 1, ReadingQuality.Measured),
Row(day.AddMinutes(1), 2, ReadingQuality.Manual),
Row(day.AddMinutes(2), 4, ReadingQuality.Imported),
Row(day.AddMinutes(3), 8, ReadingQuality.Estimated),
Row(day.AddMinutes(4), 16, ReadingQuality.Interpolated),
Row(day.AddMinutes(5), -0.5, ReadingQuality.Measured),
], Berlin);
var bucket = Assert.Single(rollups.Days);
Assert.Equal(30.5, bucket.Amount);
Assert.Equal(0.5, bucket.Measured);
Assert.Equal(2, bucket.Manual);
Assert.Equal(4, bucket.Imported);
Assert.Equal(24, bucket.Estimated);
Assert.Equal(6, bucket.Rows);
Assert.Equal(RollupFlags.None, bucket.Flags);
Assert.Equal(Provenance.Measured | Provenance.Manual | Provenance.Imported | Provenance.Estimated, bucket.Provenance);
}
[Fact]
public void Kinds_are_separate_buckets_of_the_same_day()
{
var noon = InBerlin(2026, 6, 1, 12);
var rollups = RollupBuilder.Build(
[
Row(noon, 7, kind: ConsumptionKind.Generation),
Row(noon, 3, kind: ConsumptionKind.Consumption),
], Berlin);
Assert.Equal(
[(ConsumptionKind.Consumption, 3d), (ConsumptionKind.Generation, 7d)],
rollups.Days.Select(d => (d.Kind, d.Amount)));
Assert.Equal(2, rollups.Months.Count);
}
[Fact]
public void A_bucket_is_flagged_when_any_of_its_rows_is_an_opening_balance_or_divided()
{
var rollups = RollupBuilder.Build(
[
Row(InBerlin(2026, 4, 1, 8), 700, openingBalance: true),
Row(InBerlin(2026, 4, 1, 9), 1),
Row(InBerlin(2026, 4, 30, 23, 59, 59), 20, ReadingQuality.Estimated, divided: true),
Row(InBerlin(2026, 5, 2, 9), 1),
], Berlin);
Assert.Equal(
[RollupFlags.OpeningBalance, RollupFlags.Divided, RollupFlags.None],
rollups.Days.Select(d => d.Flags));
Assert.Equal([RollupFlags.OpeningBalance | RollupFlags.Divided, RollupFlags.None], rollups.Months.Select(m => m.Flags));
Assert.True(rollups.Days[0].HasOpeningBalance);
Assert.True(rollups.Days[0].Provenance.HasFlag(Provenance.OpeningBalance));
}
[Fact]
public void The_latest_interval_end_falls_back_to_the_stamp_for_rows_without_an_interval()
{
var stamp = InBerlin(2026, 7, 3, 10);
var rollups = RollupBuilder.Build(
[
Row(stamp, 1, intervalEnd: stamp.AddHours(2)),
Row(stamp.AddHours(1), 1),
], Berlin);
Assert.Equal(stamp.AddHours(2).ToUniversalTime(), Assert.Single(rollups.Days).MaxIntervalEnd);
Assert.Equal(TimeSpan.Zero, rollups.Days[0].MaxIntervalEnd.Offset);
var bare = RollupBuilder.Build([Row(stamp, 1)], Berlin);
Assert.Equal(stamp.ToUniversalTime(), Assert.Single(bare.Days).MaxIntervalEnd);
}
[Theory]
[InlineData("UTC")]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
public void A_monthly_sheet_row_is_its_month_and_ends_where_the_month_ends(string zoneId)
{
// A-05: "Mai 2026" is the register at the end of May. Its row is stamped inside May, and it is recorded
// up to the local midnight that ends May — a reader on 20 May must not count it as an actual.
var zone = zoneId == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(zoneId);
var readings = Enumerable.Range(0, 4).Select(i => Reading(1, Month(2026, 3).AddMonths(i), 100 + (10 * i))).ToList();
var rollups = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, zone, readings), zone);
Assert.Equal(
[new DateOnly(2026, 3, 1), new DateOnly(2026, 4, 1), new DateOnly(2026, 5, 1), new DateOnly(2026, 6, 1)],
rollups.Months.Select(m => m.Start));
Assert.Equal([100d, 10d, 10d, 10d], rollups.Months.Select(m => m.Amount));
Assert.All(rollups.Months, m => Assert.Equal(m.Amount, m.Imported));
Assert.Equal(
GapAttribution.LocalMidnight(new DateOnly(2026, 6, 1), zone),
rollups.Months.Single(m => m.Start == new DateOnly(2026, 5, 1)).MaxIntervalEnd);
// One row each, filed on the 1st of its month: the day table agrees with the month table.
Assert.Equal(rollups.Months.Select(m => (m.Start, m.Amount)), rollups.Days.Select(d => (d.Start, d.Amount)));
}
[Fact]
public void A_first_reading_without_a_start_flags_its_day_but_an_install_date_does_not()
{
var readings = new[] { Measured(InBerlin(2026, 8, 3, 9), 700), Measured(InBerlin(2026, 8, 4, 9), 710) };
var unknown = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, Berlin, readings), Berlin);
Assert.Equal([RollupFlags.OpeningBalance, RollupFlags.None], unknown.Days.Select(d => d.Flags));
Assert.Equal(RollupFlags.OpeningBalance, Assert.Single(unknown.Months).Flags);
var installed = RollupBuilder.Build(
Normalize(MeterMode.CumulativeCounter, Berlin, readings, installedAt: new DateOnly(2026, 7, 1)), Berlin);
Assert.All(installed.Days, d => Assert.Equal(RollupFlags.None, d.Flags));
Assert.Equal(710, installed.Months.Sum(m => m.Amount));
}
[Fact]
public void An_interval_across_a_month_boundary_is_divided_between_both_months()
{
var august1 = InBerlin(2026, 8, 1, 9);
var september16 = InBerlin(2026, 9, 16, 18);
var rows = Normalize(MeterMode.CumulativeCounter, Berlin, [Measured(august1, 700), Measured(september16, 746)]);
var rollups = RollupBuilder.Build(rows, Berlin);
var august = rollups.Months.Single(m => m.Start == new DateOnly(2026, 8, 1));
var september = rollups.Months.Single(m => m.Start == new DateOnly(2026, 9, 1));
var augustShare = 46 * ((InBerlin(2026, 9, 1) - august1) / (september16 - august1));
Assert.Equal(700 + augustShare, august.Amount, 9);
Assert.Equal(46 - augustShare, september.Amount, 9);
// The first reading is measured; the shares are estimates, divided at 1 September.
Assert.Equal(700, august.Measured, 9);
Assert.Equal(augustShare, august.Estimated, 9);
Assert.Equal(46 - augustShare, september.Estimated, 9);
Assert.True(august.Flags.HasFlag(RollupFlags.Divided));
Assert.True(september.Flags.HasFlag(RollupFlags.Divided));
// August's share is stamped at its last second, and its interval ends at the start of September.
Assert.Equal(InBerlin(2026, 9, 1).ToUniversalTime(), august.MaxIntervalEnd);
Assert.Equal(new DateOnly(2026, 8, 31), rollups.Days.Single(d => d.Estimated != 0 && d.Start.Month == 8).Start);
}
[Fact]
public void Daily_snapshots_at_local_midnight_are_booked_in_the_day_they_close()
{
// D-11: the 00:00 reading on 11 March closes 10 March.
var readings = Enumerable.Range(0, 4)
.Select(i => Measured(InBerlin(2026, 3, 10).AddDays(i), 100 + (i * i)))
.ToList();
var rollups = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, Berlin, readings), Berlin);
Assert.Equal(
[(new DateOnly(2026, 3, 10), 101d), (new DateOnly(2026, 3, 11), 3d), (new DateOnly(2026, 3, 12), 5d)],
rollups.Days.Select(d => (d.Start, d.Amount)));
Assert.Equal(InBerlin(2026, 3, 12).ToUniversalTime(), rollups.Days[1].MaxIntervalEnd);
}
[Fact]
public void Days_and_months_both_sum_to_the_rows_and_do_not_depend_on_row_order()
{
var start = InBerlin(2026, 1, 1, 6);
// Hourly for 75 days, across two month ends and the spring DST change; uneven increments, never falling.
var readings = Enumerable.Range(0, 24 * 75)
.Select(i => Measured(start.AddHours(i), 1000 + (i * 0.77) + (0.5 * Math.Sin(i))))
.ToList();
var rows = Normalize(MeterMode.CumulativeCounter, Berlin, readings);
var rollups = RollupBuilder.Build(rows, Berlin);
var shuffled = RollupBuilder.Build(rows.Reverse().ToList(), Berlin);
Assert.Equal(rows.Sum(r => r.Amount), rollups.Days.Sum(d => d.Amount), 6);
Assert.Equal(rows.Sum(r => r.Amount), rollups.Months.Sum(m => m.Amount), 6);
Assert.Equal(rows.Count, rollups.Days.Sum(d => d.Rows));
Assert.Equal(rows.Count, rollups.Months.Sum(m => m.Rows));
foreach (var month in rollups.Months)
{
var days = rollups.Days.Where(d => RollupBuilder.MonthOf(d.Start) == month.Start).ToList();
Assert.Equal(month.Amount, days.Sum(d => d.Amount), 6);
Assert.Equal(month.Rows, days.Sum(d => d.Rows));
}
// Rebuilding the same rows, in any order, gives bit-identical buckets: a diff write touches nothing.
Assert.Equal(rollups.Days, shuffled.Days);
Assert.Equal(rollups.Months, shuffled.Months);
}
[Fact]
public void Local_day_and_month_helpers_follow_the_zone()
{
Assert.Equal(new DateOnly(2026, 3, 29), RollupBuilder.LocalDay(Utc(2026, 3, 28, 23, 30), Berlin));
Assert.Equal(new DateOnly(2026, 11, 30), RollupBuilder.LocalDay(Utc(2026, 12, 1, 3), NewYork));
Assert.Equal(new DateOnly(2026, 2, 1), RollupBuilder.MonthOf(new DateOnly(2026, 2, 28)));
}
}
@@ -0,0 +1,260 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// D-35: a heat-pump meter in cascade is a subsection of the house for quantities, but the supplier bills it at
/// its own tariff. Main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 must bill 200 × 0.30 + 100 × 0.22 — so the
/// policy has to say which meter is priced separately and which billed quantity it comes out of, without moving
/// anything in the quantity totals.
/// </summary>
public sealed class SeparatelyBilledSubmeterTests
{
private const int HeatPump = 10;
[Fact]
public void A_heat_pump_below_Haus_with_its_own_price_is_billed_separately_out_of_the_grid_import()
{
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump);
var billing = result.ForType(Electricity).Billing;
Assert.Equal([Netz], billing.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], billing.SeparatelyBilled);
Assert.True(result.IsBilled(HeatPump));
// The priceable form: Netz minus the heat pump at the normal price, the heat pump at its own.
Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions);
Assert.Equal(BillLineKind.UnitPrice, result.LineOf(Netz)!.Kind);
Assert.Equal(BillLineKind.OwnPrice, result.LineOf(HeatPump)!.Kind);
Assert.Empty(result.LineOf(HeatPump)!.Deductions);
Assert.Null(result.LineOf(Haus));
// Quantities do not move: the heat pump is still a breakdown of household use.
Assert.Equal(MeterTotalsClass.Breakdown, result.For(HeatPump).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void Without_its_own_price_a_subsection_stays_inside_its_parent_s_bill()
{
var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.False(result.IsBilled(Auto));
}
[Fact]
public void A_billed_meter_with_its_own_price_is_simply_billed_not_billed_twice()
{
var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Netz || id == Wasser);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Empty(result.ForType(Water).Billing.SeparatelyBilled);
}
[Fact]
public void Where_household_use_is_billed_the_quantity_comes_out_of_the_parent_directly()
{
var meters = Meters();
meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20);
Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled);
}
[Fact]
public void A_cascade_of_separately_billed_meters_subtracts_each_from_the_one_directly_above()
{
var meters = Meters();
meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³"));
meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21);
Assert.Equal(
[new SeparatelyBilledMeter(20, Wasser, null), new SeparatelyBilledMeter(21, 20, null)],
result.ForType(Water).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(20, 1)], result.LineOf(Wasser)!.Deductions);
Assert.Equal([new BillDeduction(21, 1)], result.LineOf(20)!.Deductions);
}
[Fact]
public void An_unlinked_meter_assumed_inside_the_total_load_is_not_billed_separately_and_its_price_is_reported_unused()
{
// Being inside the total load is only an assumption about an unlinked meter; it must not take energy out of
// the grid bill. The price names the gap, and the hint tells the user to link the meter.
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links(), id => id == HeatPump);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Empty(result.LineOf(Netz)!.Deductions);
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems);
Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, HeatPump, Haus), result.Hints);
}
[Fact]
public void A_subsection_set_to_never_is_not_billed_at_all()
{
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh", totals: TotalsOverride.Never));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems);
}
[Fact]
public void A_consumption_root_with_its_own_price_is_not_a_subsection_and_is_not_billed_separately()
{
// No link and no total_load: nothing establishes that the root sits behind the grid import, so D-35 (which is
// about containment children) does not apply. Link it or give the type a total_load meter to bill it.
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"),
];
var result = TotalsPolicy.Classify(meters, [], id => id == 21);
Assert.Equal(MeterTotalsClass.Use, result.For(21).Class);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems);
}
[Fact]
public void A_priced_consumer_linked_directly_below_the_grid_meter_is_billed_at_its_own_price()
{
// Review R6 (D-35, Kaskade): grid meter, and a heat pump behind it with its own price, linked grid → pump and
// no house meter. The link out of a supply meter is a supply edge (D-22), so the pump is a use root, not a
// containment child — yet the grid meter measured it. It is billed at its own price and taken out of the grid
// meter that links to it; the quantity measures do not change.
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"),
Physical(22, "Unlinked", Electricity, MeterMode.CumulativeCounter, "kWh"),
];
var result = TotalsPolicy.Classify(meters, [new(20, 21)], id => id is 21 or 22);
Assert.Equal(MeterTotalsClass.Use, result.For(21).Class);
var billing = result.ForType(Electricity).Billing;
Assert.Equal([new SeparatelyBilledMeter(21, 20, null)], billing.SeparatelyBilled);
Assert.Contains(billing.Lines, l => l is { MeterId: 20, Kind: BillLineKind.UnitPrice } && l.Deductions.Single().MeterId == 21);
Assert.Contains(billing.Lines, l => l is { MeterId: 21, Kind: BillLineKind.OwnPrice });
Assert.DoesNotContain(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems);
// A priced meter nothing links is still reported: nothing says the grid meter measured it.
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 22), result.Problems);
}
[Fact]
public void A_meter_price_on_a_meter_the_bill_never_prices_is_reported()
{
// Haus is not billed where the grid import is, so a price scoped to it applies to nothing.
var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus);
Assert.Equal([new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, Haus)], result.Problems);
}
[Fact]
public void A_subsection_below_meters_an_always_sum_replaced_comes_out_of_the_sum()
{
// 32 = 30 + 31 is billed in place of 30 and 31, and 33 below 30 has its own price: its quantity must come out
// of 32, or the bill charges it twice.
List<TotalsMeter> meters =
[
Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Barn water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
Physical(33, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
];
var result = TotalsPolicy.Classify(meters, [new(30, 33)], id => id == 33);
var billing = result.ForType(Water).Billing;
Assert.Equal([32], billing.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(33, 32, 30)], billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(33, 1)], result.LineOf(32)!.Deductions);
}
[Fact]
public void A_subsection_in_a_convertible_unit_is_deducted_with_its_conversion_factor()
{
var meters = Meters();
meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "L"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20);
Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(20, 0.001)], result.LineOf(Wasser)!.Deductions);
}
[Fact]
public void A_subsection_whose_unit_cannot_convert_stays_in_its_parent_s_bill_and_is_reported()
{
// An instant-rate sensor integrating to kWh cannot come out of an m³ line. It stays inside the water bill at
// the water price; the pool below it, in m³, then comes out of the next billed meter up.
var meters = Meters();
meters.Add(Physical(20, "Heat meter", Water, MeterMode.InstantRate, "kWh"));
meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21);
Assert.Equal([new SeparatelyBilledMeter(21, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(21, 1)], result.LineOf(Wasser)!.Deductions);
Assert.Null(result.LineOf(20));
Assert.Equal([new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, 20, Wasser)], result.Problems);
}
[Fact]
public void A_subsection_spanning_a_grid_meter_replacement_comes_out_of_each_grid_meter_in_service_with_it()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(11, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31)));
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(11, Haus), new(Haus, HeatPump)], id => id == HeatPump);
Assert.Equal(
[new SeparatelyBilledMeter(HeatPump, Netz, Haus), new SeparatelyBilledMeter(HeatPump, 11, Haus)],
result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions);
Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(11)!.Deductions);
Assert.Single(result.ForType(Electricity).Billing.Lines, l => l.MeterId == HeatPump);
}
[Fact]
public void The_price_predicate_is_asked_once_per_meter_while_classifying_and_never_afterwards()
{
// A reader's predicate closes over a scoped DbContext; category covers computed later must not call into it.
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var calls = 0;
var disposed = false;
bool HasPrice(int id)
{
ObjectDisposedException.ThrowIf(disposed, typeof(SeparatelyBilledSubmeterTests));
calls++;
return id == HeatPump;
}
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], HasPrice);
disposed = true;
var cover = CategoryCover.Compute(result, 101, [Haus, Netz, HeatPump]);
Assert.Equal(meters.Count, calls);
Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], cover.SeparatelyBilled);
}
}
@@ -0,0 +1,744 @@
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
public sealed class TariffUnitTests
{
// ---- Parsing ----------------------------------------------------------------------------------
[Theory]
[InlineData("EUR/kWh", "EUR", 1, "kWh", 1)]
[InlineData("€/kWh", "EUR", 1, "kWh", 1)]
[InlineData("eur/kwh", "EUR", 1, "kWh", 1)]
[InlineData("Euro/kWh", "EUR", 1, "kWh", 1)]
[InlineData("ct/kWh", "ct", 0.01, "kWh", 1)]
[InlineData("Cent/kWh", "ct", 0.01, "kWh", 1)]
[InlineData("EUR/MWh", "EUR", 1, "MWh", 1)]
[InlineData("EUR/m3", "EUR", 1, "m³", 1)]
[InlineData("EUR/m³", "EUR", 1, "m³", 1)]
[InlineData("EUR/cbm", "EUR", 1, "m³", 1)]
[InlineData("EUR/100L", "EUR", 1, "L", 100)]
[InlineData("EUR / 100 l", "EUR", 1, "L", 100)]
[InlineData("EUR/1.000 L", "EUR", 1, "L", 1000)]
[InlineData("EUR/1000L", "EUR", 1, "L", 1000)]
[InlineData("EUR/hl", "EUR", 1, "hL", 1)]
[InlineData("EUR/h", "EUR", 1, "h", 1)]
[InlineData("EUR/Std", "EUR", 1, "h", 1)]
[InlineData("EUR/t", "EUR", 1, "t", 1)]
[InlineData("EUR per kWh", "EUR", 1, "kWh", 1)]
[InlineData("EUR je 100 L", "EUR", 1, "L", 100)]
[InlineData("SEK/kWh", "SEK", 1, "kWh", 1)]
[InlineData("Fr./kWh", "CHF", 1, "kWh", 1)]
[InlineData("Rp./kWh", "Rp", 0.01, "kWh", 1)]
[InlineData("p/kWh", "p", 0.01, "kWh", 1)]
public void A_quantity_price_parses_to_currency_scale_and_denominator(
string unit, string currency, double currencyScale, string denominator, double amount)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis);
Assert.True(parsed.IsRecognised);
Assert.Equal(currency, parsed.Currency);
Assert.Equal(currencyScale, parsed.CurrencyScale);
Assert.Equal(denominator, parsed.Denominator);
Assert.Equal(amount, parsed.DenominatorAmount);
Assert.Null(parsed.Period);
}
[Theory]
[InlineData("EUR/month", BillingPeriod.Month)]
[InlineData("EUR/Monat", BillingPeriod.Month)]
[InlineData("€/Mon.", BillingPeriod.Month)]
[InlineData("€ pro Monat", BillingPeriod.Month)]
[InlineData("EUR monatlich", BillingPeriod.Month)]
[InlineData("EUR/1 Monat", BillingPeriod.Month)]
[InlineData("EUR/Jahr", BillingPeriod.Year)]
[InlineData("EUR/year", BillingPeriod.Year)]
[InlineData("EUR/a", BillingPeriod.Year)]
[InlineData("EUR p.a.", BillingPeriod.Year)]
[InlineData("EUR jährlich", BillingPeriod.Year)]
[InlineData("EUR/Tag", BillingPeriod.Day)]
[InlineData("EUR/day", BillingPeriod.Day)]
[InlineData("EUR/d", BillingPeriod.Day)]
[InlineData("EUR/12 Monate", BillingPeriod.Year)]
[InlineData("EUR/1 Jahr", BillingPeriod.Year)]
[InlineData("EUR/Quartal", BillingPeriod.Quarter)]
[InlineData("EUR/quarter", BillingPeriod.Quarter)]
[InlineData("EUR pro Quartal", BillingPeriod.Quarter)]
[InlineData("EUR vierteljährlich", BillingPeriod.Quarter)]
[InlineData("EUR/3 Monate", BillingPeriod.Quarter)]
public void A_standing_charge_parses_to_its_billing_period(string unit, BillingPeriod period)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(TariffUnitBasis.Period, parsed.Basis);
Assert.Equal(period, parsed.Period);
Assert.Equal("EUR", parsed.Currency);
Assert.Null(parsed.Denominator);
}
[Theory]
[InlineData("pauschal")]
[InlineData("EUR")]
[InlineData("kWh")]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
[InlineData("EUR/100")]
[InlineData("EUR/kWh/h")]
[InlineData("EUR/0 L")]
[InlineData("EUR/1,5 L")]
[InlineData("EUR/1.00 L")]
[InlineData("kWh/EUR")]
[InlineData("Taler/kWh")]
[InlineData("(brutto)")]
[InlineData("brutto EUR/kWh")]
public void Anything_else_is_unparseable(string? unit)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(TariffUnitBasis.Unparseable, parsed.Basis);
Assert.False(parsed.IsRecognised);
Assert.Null(parsed.Denominator);
Assert.Null(parsed.Period);
}
[Fact]
public void An_unknown_denominator_is_kept_to_match_itself()
{
var parsed = TariffUnit.Parse("EUR/Stk");
Assert.Equal(TariffUnitBasis.OtherDenominator, parsed.Basis);
Assert.Equal("Stk", parsed.Denominator);
Assert.False(parsed.IsRecognised);
}
[Theory]
[InlineData("EUR/100L", "100 L")]
[InlineData("EUR/m3", "m³")]
[InlineData("EUR/Monat", "month")]
[InlineData("EUR/a", "year")]
[InlineData("pauschal", "pauschal")]
public void The_denominator_text_says_what_the_price_is_quoted_per(string unit, string text)
{
Assert.Equal(text, TariffUnit.Parse(unit).DenominatorText);
}
[Theory]
[InlineData("EUR/kWh", TariffComponent.UnitPrice, true)]
[InlineData("ct/kWh", TariffComponent.FeedIn, true)]
[InlineData("EUR/month", TariffComponent.UnitPrice, false)]
[InlineData("EUR/Stk", TariffComponent.UnitPrice, true)]
[InlineData("pauschal", TariffComponent.UnitPrice, false)]
[InlineData("EUR/month", TariffComponent.BasePrice, true)]
[InlineData("EUR/Jahr", TariffComponent.BasePrice, true)]
[InlineData("EUR/kWh", TariffComponent.BasePrice, false)]
[InlineData("pauschal", TariffComponent.BasePrice, false)]
[InlineData("%", TariffComponent.Tax, true)]
public void The_editor_accepts_only_a_unit_shaped_for_its_component(string unit, TariffComponent component, bool suits)
{
Assert.Equal(suits, TariffUnit.Parse(unit).Suits(component));
}
// ---- Unit and feed-in prices --------------------------------------------------------------------
[Theory]
[InlineData("EUR/kWh", "kWh", 1)]
[InlineData("€/kWh", "kWh", 1)]
[InlineData("ct/kWh", "kWh", 0.01)]
[InlineData("EUR/100L", "L", 0.01)]
[InlineData("EUR/MWh", "kWh", 0.001)]
[InlineData("EUR/m3", "m³", 1)]
[InlineData("EUR/m3", "m3", 1)]
[InlineData("EUR/m³", "L", 0.001)]
[InlineData("EUR/100L", "m³", 10)]
[InlineData("EUR/kWh", "Wh", 0.001)]
[InlineData("EUR/kWh", "MWh", 1000)]
[InlineData("ct/100 L", "L", 0.0001)]
[InlineData("EUR/h", "h", 1)]
[InlineData("EUR/t", "kg", 0.001)]
public void A_matching_unit_price_applies_with_the_factor_to_currency_per_meter_unit(
string tariffUnit, string meterUnit, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.True(fit.Applies);
Assert.False(fit.NeedsWarning);
Assert.Equal(TariffUnitIssue.None, fit.Issue);
Assert.Equal(factor, fit.Factor, 12);
}
[Fact]
public void The_seeded_water_tariff_prices_the_seeded_water_meter_at_face_value()
{
// Seed: water 5.00 "EUR/m3" on a meter whose unit is "m3". Dec 2022 is 14 m³ = 70 € (D-56).
var fit = TariffUnit.Applicability("EUR/m3", "m3", TariffComponent.UnitPrice);
Assert.Equal(70d, 14 * fit.Convert(5.00), 9);
}
[Fact]
public void A_heating_oil_price_per_100_litres_is_charged_per_litre()
{
// 98.50 €/100 L on the seeded Öltank (L): 1,000 L cost 985 €, not 98,500 €.
var fit = TariffUnit.Applicability("EUR/100L", "L", TariffComponent.UnitPrice);
Assert.Equal(985d, 1000 * fit.Convert(98.50), 9);
}
[Fact]
public void A_feed_in_price_in_cents_is_converted_like_a_unit_price()
{
var fit = TariffUnit.Applicability("ct/kWh", "kWh", TariffComponent.FeedIn);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(0.082, fit.Convert(8.2), 12);
}
[Fact]
public void An_energy_price_does_not_price_water()
{
var fit = TariffUnit.Applicability("EUR/kWh", "m³", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.False(fit.Applies);
Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue);
Assert.Equal("kWh", fit.TariffDenominator);
Assert.Equal("m³", fit.MeterUnit);
Assert.Equal("EUR/kWh", fit.TariffUnit);
Assert.Equal(0d, fit.Convert(0.30));
}
[Theory]
// A global electricity price used to price water and burner hours too (costing review, §10.2).
[InlineData("EUR/kWh", "h")]
[InlineData("EUR/100L", "h")]
[InlineData("EUR/h", "L")]
[InlineData("EUR/kWh", "kW")]
[InlineData("EUR/L", "L/min")]
[InlineData("EUR/m³", "m³/h")]
public void A_price_in_another_dimension_is_a_mismatch(string tariffUnit, string meterUnit)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue);
}
[Theory]
[InlineData("EUR/kWh", "Stk", 1)]
[InlineData("EUR/kWh", "kWh (el)", 1)]
[InlineData("EUR/m³", "Nm³", 1)]
[InlineData("ct/kWh", "Einheiten", 0.01)]
[InlineData("EUR/kWh", "mwh", 1)]
public void A_meter_unit_the_module_does_not_recognise_is_unverified_not_a_mismatch(
string tariffUnit, string meterUnit, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.True(fit.Applies);
Assert.True(fit.NeedsWarning);
Assert.Equal(TariffUnitIssue.MeterUnitUnrecognised, fit.Issue);
Assert.Equal(factor, fit.Factor, 12);
}
[Theory]
[InlineData("EUR/kWh", "Kilowattstunden", 1)]
[InlineData("EUR/kWh", "kilowatt-hours", 1)]
[InlineData("EUR/MWh", "Kilowattstunde", 0.001)]
[InlineData("ct/kWh", "Wattstunden", 0.00001)]
[InlineData("EUR/Megawattstunde", "kWh", 0.001)]
public void Long_form_and_German_energy_units_are_priced_like_their_symbols(string tariffUnit, string meterUnit, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(factor, fit.Factor, 12);
}
[Theory]
[InlineData("mWh", 0.000_001)]
[InlineData("MWh", 1000)]
[InlineData("MWH", 1000)]
public void A_milli_unit_is_never_priced_as_a_mega_unit(string meterUnit, double factor)
{
var fit = TariffUnit.Applicability("EUR/kWh", meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(factor, fit.Factor, 12);
}
[Fact]
public void A_standing_charge_unit_on_a_unit_price_is_a_mismatch()
{
var fit = TariffUnit.Applicability("EUR/month", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue);
Assert.Equal("month", fit.TariffDenominator);
}
[Fact]
public void An_unparseable_unit_price_applies_at_face_value_with_a_warning()
{
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.True(fit.Applies);
Assert.True(fit.NeedsWarning);
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
Assert.Equal(1d, fit.Factor);
Assert.Equal("pauschal", fit.TariffDenominator);
}
[Fact]
public void An_unparseable_unit_in_cents_still_converts_the_currency()
{
var fit = TariffUnit.Applicability("ct", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(0.01, fit.Factor);
}
[Fact]
public void An_unknown_denominator_applies_to_a_meter_in_that_unit()
{
var fit = TariffUnit.Applicability("EUR/Stk", "stk", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(1d, fit.Factor);
}
[Fact]
public void An_unknown_denominator_on_another_meter_applies_with_a_warning()
{
var fit = TariffUnit.Applicability("EUR/Stk", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(TariffUnitIssue.UnknownDenominator, fit.Issue);
Assert.Equal(1d, fit.Factor);
}
// ---- Qualifiers ---------------------------------------------------------------------------------
[Theory]
[InlineData("€/kWh brutto", TariffUnitBasis.Quantity, "kWh", "brutto")]
[InlineData("EUR/kWh netto", TariffUnitBasis.Quantity, "kWh", "netto")]
[InlineData("EUR/kWh zzgl. 19 % MwSt.", TariffUnitBasis.Quantity, "kWh", "zzgl. 19 % MwSt.")]
[InlineData("ct/kWh, inkl. USt", TariffUnitBasis.Quantity, "kWh", "inkl. USt")]
[InlineData("EUR/kWh (Grundversorgung)", TariffUnitBasis.Quantity, "kWh", "Grundversorgung")]
[InlineData("EUR/100 L [Heizöl EL] incl. VAT", TariffUnitBasis.Quantity, "100 L", "incl. VAT Heizöl EL")]
[InlineData("€/Jahr inkl. MwSt.", TariffUnitBasis.Period, "year", "inkl. MwSt.")]
[InlineData("EUR/Monat (netto)", TariffUnitBasis.Period, "month", "netto")]
[InlineData("EUR per kWh gross", TariffUnitBasis.Quantity, "kWh", "gross")]
public void VAT_notes_and_bracketed_text_are_set_aside_before_the_unit_is_read(
string unit, TariffUnitBasis basis, string denominator, string qualifier)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(basis, parsed.Basis);
Assert.Equal(denominator, parsed.DenominatorText);
Assert.Equal(qualifier, parsed.Qualifier);
Assert.Equal(unit, parsed.Raw);
}
[Fact]
public void A_unit_without_a_note_has_no_qualifier()
{
Assert.Null(TariffUnit.Parse("EUR/kWh").Qualifier);
Assert.Null(TariffUnit.Parse("pauschal").Qualifier);
}
[Fact]
public void A_price_with_a_VAT_note_applies_cleanly()
{
var fit = TariffUnit.Applicability("€/kWh brutto", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(1d, fit.Factor);
Assert.Equal("€/kWh brutto", fit.TariffUnit);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void A_meter_without_a_unit_cannot_be_checked(string? meterUnit)
{
var fit = TariffUnit.Applicability("EUR/100L", meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(TariffUnitIssue.MeterUnitUnknown, fit.Issue);
Assert.Equal(0.01, fit.Factor);
}
[Theory]
[InlineData(TariffComponent.Bonus)]
[InlineData(TariffComponent.Discount)]
[InlineData(TariffComponent.Tax)]
public void Bonus_discount_and_tax_are_not_applied(TariffComponent component)
{
var fit = TariffUnit.Applicability("EUR/kWh", "kWh", component);
Assert.Equal(TariffUnitFit.NotApplied, fit.Fit);
Assert.False(fit.Applies);
Assert.Equal(TariffUnitIssue.ComponentNotApplied, fit.Issue);
Assert.Equal(0d, fit.Convert(12));
}
// ---- Standing charges ---------------------------------------------------------------------------
[Theory]
[InlineData("EUR/month", BillingPeriod.Month)]
[InlineData("EUR/Jahr", BillingPeriod.Year)]
[InlineData("EUR/Tag", BillingPeriod.Day)]
public void A_standing_charge_applies_per_its_period_whatever_the_meter_unit(string unit, BillingPeriod period)
{
var fit = TariffUnit.Applicability(unit, "m³", TariffComponent.BasePrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(period, fit.Period);
Assert.Equal(1d, fit.Factor);
}
[Fact]
public void A_monthly_charge_accrues_over_the_days_of_each_local_month()
{
var accrual = TariffUnit.BaseAccrual("EUR/month");
Assert.Equal(BillingPeriod.Month, accrual.Period);
Assert.False(accrual.Assumed);
Assert.Equal(12d / 28, accrual.PerDay(12, new DateOnly(2026, 2, 10)), 12);
Assert.Equal(12d / 29, accrual.PerDay(12, new DateOnly(2024, 2, 10)), 12);
Assert.Equal(12d / 31, accrual.PerDay(12, new DateOnly(2026, 1, 31)), 12);
Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2026, 2, 1), new DateOnly(2026, 3, 1)), 9);
Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2024, 2, 1), new DateOnly(2024, 3, 1)), 9);
}
[Fact]
public void A_yearly_charge_accrues_over_the_days_of_the_local_year()
{
var accrual = TariffUnit.BaseAccrual("EUR/Jahr");
Assert.Equal(BillingPeriod.Year, accrual.Period);
Assert.Equal(120d / 366, accrual.PerDay(120, new DateOnly(2024, 7, 1)), 12);
Assert.Equal(120d / 365, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12);
Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2024, 1, 1), new DateOnly(2025, 1, 1)), 9);
Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9);
}
[Fact]
public void A_daily_charge_is_charged_as_is_and_a_charge_in_cents_is_converted()
{
Assert.Equal(0.5, TariffUnit.BaseAccrual("EUR/Tag").PerDay(0.5, new DateOnly(2026, 2, 1)), 12);
Assert.Equal(0.5, TariffUnit.BaseAccrual("ct/Tag").PerDay(50, new DateOnly(2026, 2, 1)), 12);
}
[Fact]
public void The_accrual_takes_the_month_length_directly()
{
var accrual = TariffUnit.BaseAccrual("EUR/Monat");
Assert.Equal(1d, accrual.PerDay(30, daysInMonth: 30, daysInYear: 365), 12);
Assert.Throws<ArgumentOutOfRangeException>(() => accrual.PerDay(30, daysInMonth: 0, daysInYear: 365));
}
[Fact]
public void An_unparseable_standing_charge_is_taken_per_month_with_a_warning()
{
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.BasePrice);
var accrual = TariffUnit.BaseAccrual("pauschal");
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.True(fit.NeedsWarning);
Assert.Equal(BillingPeriod.Month, fit.Period);
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
Assert.Equal(BillingPeriod.Month, accrual.Period);
Assert.True(accrual.Assumed);
Assert.Equal(10d / 30, accrual.PerDay(10, new DateOnly(2026, 4, 15)), 12);
}
[Theory]
[InlineData("€/Jahr inkl. MwSt.", 120d / 365)]
[InlineData("EUR/Jahr brutto", 120d / 365)]
[InlineData("EUR/12 Monate", 120d / 365)]
[InlineData("EUR/Quartal", 120d / 92)]
public void A_standing_charge_is_never_taken_per_month_when_its_period_can_be_read(string unit, double perDay)
{
var accrual = TariffUnit.BaseAccrual(unit);
Assert.False(accrual.Assumed);
Assert.Equal(TariffUnitFit.Applies, accrual.Fit);
Assert.Equal(perDay, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12);
}
[Fact]
public void A_quarterly_charge_accrues_over_the_days_of_its_local_calendar_quarter()
{
var accrual = TariffUnit.BaseAccrual("EUR/Quartal");
Assert.Equal(BillingPeriod.Quarter, accrual.Period);
Assert.Equal(90, accrual.DaysInPeriod(new DateOnly(2026, 2, 14)));
Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2024, 3, 31)));
Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2026, 4, 1)));
Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 8, 31)));
Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 12, 31)));
Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2024, 1, 1), new DateOnly(2024, 4, 1)), 9);
Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2026, 7, 1), new DateOnly(2026, 10, 1)), 9);
Assert.Equal(120d, SumOverDays(accrual, 30, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9);
}
[Fact]
public void A_quarterly_charge_cannot_be_spread_by_month_and_year_lengths_alone()
{
var accrual = TariffUnit.BaseAccrual("EUR/Quartal");
Assert.Throws<InvalidOperationException>(() => accrual.PerDay(30, daysInMonth: 31, daysInYear: 365));
}
[Theory]
[InlineData("EUR/2 Monate", "2 Monate")]
[InlineData("EUR/Woche", "Woche")]
[InlineData("EUR/Halbjahr", "Halbjahr")]
[InlineData("EUR/7 Tage", "7 Tage")]
[InlineData("EUR halbjährlich", "halbjährlich")]
public void A_period_that_is_read_but_cannot_be_accrued_is_a_mismatch_not_a_month(string unit, string written)
{
var parsed = TariffUnit.Parse(unit);
var fit = TariffUnit.Applicability(unit, "kWh", TariffComponent.BasePrice);
var accrual = TariffUnit.BaseAccrual(unit);
Assert.Equal(TariffUnitBasis.UnsupportedPeriod, parsed.Basis);
Assert.True(parsed.IsRecognised);
Assert.Equal(written, parsed.DenominatorText);
Assert.False(parsed.Suits(TariffComponent.BasePrice));
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.UnsupportedPeriod, fit.Issue);
Assert.Equal(0d, fit.Factor);
Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit);
Assert.False(accrual.Applies);
Assert.False(accrual.Assumed);
Assert.Equal(0d, accrual.PerDay(24, new DateOnly(2026, 5, 1)));
}
[Fact]
public void A_period_on_a_unit_price_is_a_mismatch_even_when_it_cannot_be_accrued()
{
var fit = TariffUnit.Applicability("EUR/2 Monate", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue);
}
[Fact]
public void A_standing_charge_quoted_per_quantity_is_taken_per_month_with_a_warning()
{
var accrual = TariffUnit.BaseAccrual("EUR/kWh");
Assert.True(accrual.Assumed);
Assert.Equal(BillingPeriod.Month, accrual.Period);
Assert.Equal(TariffUnitIssue.QuantityForPeriod, accrual.Issue);
}
// ---- Currency -----------------------------------------------------------------------------------
[Theory]
[InlineData("USD/kWh", "EUR")]
[InlineData("SEK/kWh", "EUR")]
[InlineData("£/kWh", "EUR")]
[InlineData("p/kWh", "EUR")]
[InlineData("Rp./kWh", "EUR")]
[InlineData("ct/kWh", "GBP")]
[InlineData("EUR/kWh", "CHF")]
[InlineData("USD", "EUR")]
public void A_price_in_another_currency_does_not_price_the_instance_currency(string tariffUnit, string currency)
{
var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue);
Assert.Equal(0d, fit.Convert(0.30));
}
[Theory]
[InlineData("EUR/kWh", "EUR", 1)]
[InlineData("€/kWh", "eur", 1)]
[InlineData("EUR/kWh", "€", 1)]
[InlineData("ct/kWh", "EUR", 0.01)]
[InlineData("Cent/kWh", "USD", 0.01)]
[InlineData("p/kWh", "GBP", 0.01)]
[InlineData("Rp./kWh", "CHF", 0.01)]
[InlineData("Fr./kWh", "CHF", 1)]
[InlineData("SEK/kWh", "sek", 1)]
public void A_minor_unit_applies_only_under_its_own_major_currency(string tariffUnit, string currency, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(factor, fit.Factor, 12);
}
[Fact]
public void Without_an_expected_currency_the_currency_is_not_checked()
{
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.UnitPrice).Fit);
}
[Fact]
public void A_unit_without_a_currency_cannot_be_checked_against_one()
{
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice, "EUR");
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
}
[Fact]
public void A_standing_charge_in_another_currency_accrues_nothing()
{
var fit = TariffUnit.Applicability("USD/month", "kWh", TariffComponent.BasePrice, "EUR");
var accrual = TariffUnit.BaseAccrual("USD/month", "EUR");
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue);
Assert.Equal(BillingPeriod.Month, fit.Period);
Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit);
Assert.Equal(0d, accrual.PerDay(12, new DateOnly(2026, 2, 1)));
Assert.Equal(TariffUnitFit.Applies, TariffUnit.BaseAccrual("EUR/month", "EUR").Fit);
}
[Fact]
public void Bonus_discount_and_tax_stay_not_applied_whatever_their_currency()
{
Assert.Equal(TariffUnitFit.NotApplied, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.Tax, "EUR").Fit);
}
[Fact]
public void The_expected_currency_must_be_named()
{
Assert.Throws<ArgumentException>(() => TariffUnit.Applicability("EUR/kWh", "kWh", TariffComponent.UnitPrice, " "));
Assert.Throws<ArgumentException>(() => TariffUnit.BaseAccrual("EUR/month", ""));
}
// ---- Parsed once --------------------------------------------------------------------------------
[Theory]
[InlineData("EUR/kWh", "MWh", TariffComponent.UnitPrice)]
[InlineData("EUR/100L", "L", TariffComponent.UnitPrice)]
[InlineData("pauschal", "kWh", TariffComponent.UnitPrice)]
[InlineData("EUR/Jahr", "m³", TariffComponent.BasePrice)]
[InlineData("USD/kWh", "kWh", TariffComponent.FeedIn)]
[InlineData("EUR/kWh", "kWh", TariffComponent.Tax)]
public void A_unit_parsed_once_is_checked_exactly_like_its_text(string tariffUnit, string meterUnit, TariffComponent component)
{
var parsed = TariffUnit.Parse(tariffUnit);
Assert.Equal(
TariffUnit.Applicability(tariffUnit, meterUnit, component),
TariffUnit.Applicability(parsed, meterUnit, component));
Assert.Equal(
TariffUnit.Applicability(tariffUnit, meterUnit, component, "EUR"),
TariffUnit.Applicability(parsed, meterUnit, component, "EUR"));
Assert.Equal(TariffUnit.BaseAccrual(tariffUnit), TariffUnit.BaseAccrual(parsed));
Assert.Equal(TariffUnit.BaseAccrual(tariffUnit, "EUR"), TariffUnit.BaseAccrual(parsed, "EUR"));
}
// ---- Alias tables -------------------------------------------------------------------------------
public static TheoryData<string, string, double, string> CurrencyAliases
{
get
{
var data = new TheoryData<string, string, double, string>();
foreach (var entry in TariffUnit.CurrencyAliasTable)
{
data.Add(entry.Alias, entry.Code, entry.Scale, entry.Majors[0]);
}
return data;
}
}
public static TheoryData<string> PeriodAliases
{
get
{
var data = new TheoryData<string>();
foreach (var entry in TariffUnit.PeriodAliasTable)
{
data.Add(entry.Alias);
}
return data;
}
}
[Theory]
[MemberData(nameof(CurrencyAliases))]
public void Every_currency_spelling_parses_to_its_code_and_scale_and_prices_its_major_currency(
string alias, string code, double scale, string major)
{
var parsed = TariffUnit.Parse($"{alias}/kWh");
Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis);
Assert.Equal(code, parsed.Currency);
Assert.Equal(scale, parsed.CurrencyScale);
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(parsed, "kWh", TariffComponent.UnitPrice, major).Fit);
}
[Theory]
[MemberData(nameof(PeriodAliases))]
public void Every_period_spelling_is_read_as_a_period(string alias)
{
var slashed = TariffUnit.Parse($"EUR/{alias}");
var spaced = TariffUnit.Parse($"EUR pro {alias}");
Assert.Contains(slashed.Basis, new[] { TariffUnitBasis.Period, TariffUnitBasis.UnsupportedPeriod });
Assert.Equal(slashed.Basis, spaced.Basis);
Assert.Equal(slashed.Period, spaced.Period);
if (slashed.Basis == TariffUnitBasis.Period)
{
// A supported period round-trips through the token it is written with.
Assert.Equal(slashed.Period, TariffUnit.Parse($"EUR/{TariffUnit.PeriodToken(slashed.Period!.Value)}").Period);
}
}
// ---- Suggestions --------------------------------------------------------------------------------
[Theory]
[InlineData(TariffComponent.UnitPrice, "m3", "EUR/m³")]
[InlineData(TariffComponent.FeedIn, "kWh", "EUR/kWh")]
[InlineData(TariffComponent.BasePrice, "kWh", "EUR/month")]
[InlineData(TariffComponent.UnitPrice, "", "EUR")]
[InlineData(TariffComponent.Tax, "kWh", "EUR")]
public void The_editor_suggests_a_unit_for_the_component(TariffComponent component, string meterUnit, string expected)
{
Assert.Equal(expected, TariffUnit.Suggest(component, meterUnit));
}
[Theory]
[InlineData(TariffComponent.UnitPrice, "m3")]
[InlineData(TariffComponent.FeedIn, "kWh")]
[InlineData(TariffComponent.UnitPrice, "L")]
[InlineData(TariffComponent.BasePrice, "kWh")]
public void A_suggested_unit_applies_cleanly_to_the_meter_it_was_suggested_for(TariffComponent component, string meterUnit)
{
var suggested = TariffUnit.Suggest(component, meterUnit);
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(suggested, meterUnit, component).Fit);
Assert.True(TariffUnit.Parse(suggested).Suits(component));
}
private static double SumOverDays(BasePriceAccrual accrual, double value, DateOnly from, DateOnly to)
{
var total = 0d;
for (var day = from; day < to; day = day.AddDays(1))
{
total += accrual.PerDay(value, day);
}
return total;
}
}
@@ -0,0 +1,940 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The per-type totals policy (D-22, D-23, D-34, D-35, D-53). The seeded topology is the golden case: summing
/// every meter there counts Haus, Netz and Auto on top of each other, and the sheet bills Netz alone — so these
/// tests pin that the policy finds exactly one non-overlapping meter per measure, bills the grid import, and
/// refuses any override that would count the same energy twice.
/// </summary>
public sealed class TotalsPolicyTests
{
[Fact]
public void The_seeded_topology_classifies_every_meter_as_the_note_pins()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal(MeterTotalsReason.TotalLoadRole, result.For(Haus).Reason);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal(Haus, result.For(Auto).ParentId);
Assert.Equal(MeterTotalsReason.ContainedByLink, result.For(Auto).Reason);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar2).Class);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(SummeSolar).Class);
Assert.Equal(MeterTotalsReason.VirtualView, result.For(SummeSolar).Reason);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(Wasser).Reason);
Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class);
Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class);
}
[Fact]
public void Seeded_measures_are_one_set_per_type_and_unit()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Equal(
[
new MeasureGroup(TotalsMeasure.Use, "kWh", [Haus]),
new MeasureGroup(TotalsMeasure.GridImport, "kWh", [Netz]),
new MeasureGroup(TotalsMeasure.Generation, "kWh", [Solar1, Solar2]),
],
result.ForType(Electricity).Measures,
MeasureGroupComparer.Instance);
Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [Wasser])], result.ForType(Water).Measures, MeasureGroupComparer.Instance);
Assert.Equal(
[
new MeasureGroup(TotalsMeasure.Use, "L", [Oeltank]),
new MeasureGroup(TotalsMeasure.Runtime, "h", [Brenner]),
],
result.ForType(Oil).Measures,
MeasureGroupComparer.Instance);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Export));
}
[Fact]
public void The_seeded_bill_is_the_grid_import_for_electricity_and_household_use_elsewhere()
{
var result = TotalsPolicy.Classify(Meters(), Links());
var electricity = result.ForType(Electricity).Billing;
Assert.Equal(BillingBasis.GridImport, electricity.Basis);
Assert.Equal([Netz], electricity.BilledMeterIds);
Assert.Empty(electricity.FeedInMeterIds);
Assert.Empty(electricity.SeparatelyBilled);
Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis);
Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds);
Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds);
Assert.Equal([Netz, Wasser, Oeltank], result.BillItems.Order());
Assert.False(result.IsBilled(Solar1));
Assert.False(result.IsBilled(Brenner));
}
[Fact]
public void The_seeded_configuration_raises_no_problem_and_no_overlap_hint()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Empty(result.Problems);
Assert.Empty(result.Hints);
Assert.Empty(TotalsPolicy.PossibleOverlapHints(Meters(), Links()));
}
[Fact]
public void A_link_out_of_a_supply_meter_never_makes_its_target_a_subsection()
{
// Netz → Haus and Summe Solar → Haus say what feeds the house, not that the house is part of them.
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Empty(result.For(Haus).ParentIds);
Assert.Empty(result.For(SummeSolar).ParentIds);
}
[Fact]
public void Lifecycle_dates_move_no_meter_where_no_role_passes_between_meters()
{
var plain = TotalsPolicy.Classify(Meters(), Links());
var dated = TotalsPolicy.Classify(
[.. Meters().Select(m => m.Id switch
{
Auto => m with { RetiredAt = new DateOnly(2024, 6, 30) },
Solar2 => m with { InstalledAt = new DateOnly(2025, 3, 1) },
Netz => m with { InstalledAt = new DateOnly(2022, 9, 1), RetiredAt = new DateOnly(2023, 1, 31) },
_ => m,
})],
Links());
foreach (var id in plain.Meters.Keys)
{
Assert.Equal(plain.For(id).Class, dated.For(id).Class);
Assert.Equal(plain.For(id).Measure, dated.For(id).Measure);
}
Assert.Equal(plain.BillItems.Order(), dated.BillItems.Order());
}
[Fact]
public void The_result_does_not_depend_on_the_order_meters_and_links_arrive_in()
{
var forward = TotalsPolicy.Classify(Meters(), Links());
var backward = TotalsPolicy.Classify(Enumerable.Reverse(Meters()), Enumerable.Reverse(Links()));
foreach (var id in forward.Meters.Keys)
{
Assert.Equal(forward.For(id), backward.For(id), EntryComparer.Instance);
}
}
[Fact]
public void A_bidirectional_meter_without_total_load_bills_the_import_credits_the_export_and_leaves_use_unmeasured()
{
// Import 1.8.0, export 2.8.0 and PV, no house meter: none of them is household use — the import is supply,
// the export is never consumption, and generation is not use. Use stays empty rather than guessed; the bill
// is the import and the feed-in credit is earned by the export register only (never by generation).
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
];
var totals = TotalsPolicy.Classify(meters, [new(22, 20)]).ForType(Electricity);
Assert.Empty(totals.MetersIn(TotalsMeasure.Use));
Assert.Equal([20], totals.MetersIn(TotalsMeasure.GridImport));
Assert.Equal([21], totals.MetersIn(TotalsMeasure.Export));
Assert.Equal([22], totals.MetersIn(TotalsMeasure.Generation));
Assert.Equal(BillingBasis.GridImport, totals.Billing.Basis);
Assert.Equal([20], totals.Billing.BilledMeterIds);
Assert.Equal([21], totals.Billing.FeedInMeterIds);
}
[Fact]
public void A_virtual_meter_never_holds_a_role_so_a_virtual_total_load_stays_an_analysis_view()
{
// A-07: a virtual meter is a view over other meters. Counting a stored total_load on it would put a calculation
// into household use (and bill it where no grid import exists), which D-39 allows only through Always.
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
Virtual(23, "Household", Electricity, "kWh", QuantityKind.Net, [20, 21, 22], pureSum: false, MeterRoles.TotalLoad),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Null(meters[3].Role);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([20], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Empty(result.Problems);
}
[Fact]
public void A_virtual_total_load_in_a_type_without_a_grid_import_is_never_billed()
{
// The probed case: the role made the view household use, and with no grid import that meant billing it.
var meters = Meters();
meters.Add(Virtual(22, "Wasser view", Water, "m³", QuantityKind.Consumption, [Wasser], pureSum: true, MeterRoles.TotalLoad));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(22).Class);
Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
}
[Fact]
public void A_role_handed_directly_to_a_virtual_meter_is_ignored_and_reported()
{
// A caller that skips MeterRoleRules.Effective still cannot make a view hold a role.
var meters = Meters();
meters.Add(Virtual(23, "House view", Electricity, "kWh", QuantityKind.Consumption, [Haus], pureSum: true) with { Role = MeterRole.TotalLoad });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, 23, Role: MeterRole.TotalLoad), result.Problems);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
}
[Theory]
[InlineData("GRID_IMPORT")]
[InlineData(" Grid_Import ")]
[InlineData("grid_import")]
public void A_role_token_counts_whatever_its_case_and_surrounding_spaces(string token)
{
var result = TotalsPolicy.Classify(
MetersWith(Netz, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)),
Links());
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Empty(result.Problems);
}
[Theory]
[InlineData(MeterRoles.GridImport)]
[InlineData(MeterRoles.GridExport)]
[InlineData(MeterRoles.TotalLoad)]
public void A_role_stored_on_a_tank_plays_no_part_so_the_tank_is_billed_as_household_use(string token)
{
// A tank measures a store, not a flow (MeterRoleRules): a stored grid_import must not bill it as the grid,
// and a stored grid_export must not earn it a feed-in credit.
var result = TotalsPolicy.Classify(
MetersWith(Oeltank, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)),
Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class);
Assert.Equal(BillingBasis.Use, result.ForType(Oil).Billing.Basis);
Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds);
Assert.Empty(result.ForType(Oil).Billing.FeedInMeterIds);
}
[Fact]
public void A_meter_reporting_export_is_never_consumption_even_without_the_role()
{
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", kind: QuantityKind.Export),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal(MeterTotalsClass.Export, result.For(21).Class);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([21], result.ForType(Electricity).Billing.FeedInMeterIds);
}
[Fact]
public void Always_on_Summe_Solar_replaces_both_solar_meters_in_generation()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(SummeSolar).Class);
Assert.Equal(TotalsMeasure.Generation, result.For(SummeSolar).Measure);
Assert.Equal([Solar1, Solar2], result.For(SummeSolar).ReplacesIds);
foreach (var solar in new[] { Solar1, Solar2 })
{
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(solar).Class);
Assert.Equal(MeterTotalsReason.CoveredByOverride, result.For(solar).Reason);
Assert.Equal(SummeSolar, result.For(solar).RelatedMeterId);
Assert.Equal(MeterTotalsClass.Generation, result.For(solar).Natural);
}
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), SummeSolar, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void Always_on_Auto_while_Haus_is_counted_is_refused_naming_Haus()
{
var check = TotalsPolicy.Validate(Meters(), Links(), Auto, TotalsOverride.Always);
Assert.False(check.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void A_stored_always_that_overlaps_is_not_applied_and_is_reported()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), result.For(Auto).RefusedOverride);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, Auto, Haus, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems);
}
[Fact]
public void Always_on_Auto_is_allowed_once_Haus_is_set_to_never()
{
var neverHaus = MetersWithOverride((Haus, TotalsOverride.Never));
Assert.True(TotalsPolicy.Validate(neverHaus, Links(), Auto, TotalsOverride.Always).IsAllowed);
var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Haus).Class);
Assert.Equal(MeterTotalsReason.OverrideNever, result.For(Haus).Reason);
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(Auto).Class);
Assert.Empty(result.For(Auto).ReplacesIds);
Assert.Equal([Auto], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
}
[Fact]
public void Putting_Haus_back_to_auto_is_refused_while_Autos_always_depends_on_its_absence()
{
var meters = MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), Haus, TotalsOverride.Auto);
Assert.Equal(new TotalsConflict(TotalsConflictReason.DisplacesOverride, Auto), check.Conflict);
}
[Fact]
public void Always_on_a_solar_meter_that_Summe_Solar_replaces_is_refused_naming_Summe_Solar()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), Solar1, TotalsOverride.Always);
Assert.False(check.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), check.Conflict);
}
[Fact]
public void Always_on_a_virtual_that_depends_on_an_always_meter_is_refused_naming_it()
{
var meters = MetersWithOverride((Solar1, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), SummeSolar, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Solar1), check.Conflict);
}
[Fact]
public void Two_stored_always_sums_over_the_same_sources_keep_the_lower_id_and_report_the_other()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
meters.Add(Virtual(10, "PV total (copy)", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true, totals: TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(10).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), result.For(10).RefusedOverride);
Assert.Contains(result.Problems, p => p.Kind == TotalsProblemKind.OverrideRefused && p.MeterId == 10);
}
[Fact]
public void Always_on_a_virtual_over_a_subsection_is_refused_naming_its_counted_ancestor()
{
var meters = Meters();
meters.Add(Virtual(10, "Car only", Electricity, "kWh", QuantityKind.Consumption, [Auto], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void Always_on_a_virtual_whose_sources_nest_is_refused_because_its_sum_double_counts()
{
var meters = Meters();
meters.Add(Virtual(10, "House plus car", Electricity, "kWh", QuantityKind.Consumption, [Haus, Auto], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Haus), check.Conflict);
}
[Fact]
public void Always_on_a_formula_that_is_not_a_pure_sum_is_refused()
{
var meters = Meters();
meters.Add(Virtual(10, "Netz Einsparung", Electricity, "kWh", QuantityKind.Consumption, [Haus, Netz], pureSum: false));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAPureSum, null), check.Conflict);
}
[Theory]
[InlineData(QuantityKind.Net)]
[InlineData(QuantityKind.Indicator)]
public void Always_on_a_net_or_indicator_view_is_refused_as_not_additive(QuantityKind kind)
{
var meters = Meters();
meters.Add(Virtual(10, "View", Electricity, "kWh", kind, [Haus, Netz], pureSum: false));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), check.Conflict);
}
[Fact]
public void A_virtual_without_a_usable_definition_cannot_be_counted()
{
var meters = Meters();
meters.Add(Virtual(10, "Needs configuration", Electricity, "kWh", QuantityKind.Consumption, [], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(TotalsConflictReason.NotAPureSum, check.Conflict!.Reason);
}
[Fact]
public void Always_on_a_consumption_sum_of_roots_replaces_them_in_use_and_in_the_bill()
{
List<TotalsMeter> meters =
[
Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal([32], result.ForType(Water).MetersIn(TotalsMeasure.Use));
Assert.Equal([30, 31], result.For(32).ReplacesIds);
Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis);
Assert.Equal([32], result.ForType(Water).Billing.BilledMeterIds);
}
[Fact]
public void Always_on_a_meter_that_already_counts_changes_nothing()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Null(result.For(Haus).RefusedOverride);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), Haus, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void Never_on_the_grid_import_makes_the_type_bill_household_use()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Netz, TotalsOverride.Never)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Natural);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.GridImport));
Assert.Equal(BillingBasis.Use, result.ForType(Electricity).Billing.Basis);
Assert.Equal([Haus], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), Netz, TotalsOverride.Never).IsAllowed);
}
[Fact]
public void Never_keeps_a_subsection_s_parent_so_the_page_can_still_explain_it()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Never)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Auto).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Natural);
Assert.Equal([Haus], result.For(Auto).ParentIds);
}
[Fact]
public void A_duplicated_role_is_kept_by_the_lowest_id_and_the_other_meter_is_reported()
{
var meters = Meters();
meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class);
Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason);
Assert.Equal(Netz, result.For(10).RelatedMeterId);
Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
// The loser still measures the grid: its link into Haus is a supply edge, and it never joins household use.
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal(TotalsConflictReason.RoleConflict, TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always).Conflict!.Reason);
}
[Fact]
public void The_same_role_in_different_energy_types_is_no_conflict()
{
var meters = Meters();
meters.Add(Physical(10, "Main water", Water, MeterMode.CumulativeCounter, "m³", MeterRoles.TotalLoad));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Wasser)]);
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
Assert.Equal([10], result.ForType(Water).MetersIn(TotalsMeasure.Use));
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Wasser).Class);
}
[Fact]
public void A_role_handed_to_a_mode_that_cannot_hold_it_is_ignored_and_reported()
{
var meters = MetersWith(Solar1, m => m with { Role = MeterRole.TotalLoad });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, Solar1, Role: MeterRole.TotalLoad), result.Problems);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void An_unlinked_consumption_meter_is_taken_as_part_of_the_total_load_and_hinted()
{
var meters = Meters();
meters.Add(Physical(10, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsClass.Breakdown, result.For(10).Class);
Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(10).Reason);
Assert.Equal(Haus, result.For(10).ParentId);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, 10, Haus), result.Hints);
// Counting it as well would add a part of Haus on top of Haus.
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void A_grid_import_not_linked_to_the_total_load_raises_a_possible_overlap_hint()
{
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList();
var result = TotalsPolicy.Classify(Meters(), links);
Assert.Equal([new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, Electricity, Netz, Haus)], result.Hints);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void A_grid_import_reaching_the_total_load_through_another_meter_is_linked()
{
var meters = Meters();
meters.Add(Physical(10, "Sub-distribution", Electricity, MeterMode.CumulativeCounter, "kWh"));
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).Append(new(Netz, 10)).Append(new(10, Haus)).ToList();
var result = TotalsPolicy.Classify(meters, links);
Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad);
}
[Fact]
public void A_generation_meter_below_another_generation_meter_is_a_breakdown_not_a_second_root()
{
var meters = Meters();
meters.Add(Physical(10, "Inverter", Electricity, MeterMode.GenerationCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Solar1)]);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Solar1).Class);
Assert.Equal(10, result.For(Solar1).ParentId);
Assert.Equal([Solar2, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Runtime_meters_all_count_even_when_linked_below_a_tank()
{
var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Oeltank, Brenner)]);
Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class);
Assert.Equal([Oeltank], result.ForType(Oil).MetersIn(TotalsMeasure.Use));
Assert.Equal([Brenner], result.ForType(Oil).MetersIn(TotalsMeasure.Runtime));
}
[Fact]
public void Measures_never_add_across_units()
{
List<TotalsMeter> meters =
[
Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Cistern", Water, MeterMode.DirectDelta, "L"),
];
var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use);
Assert.Equal(
[new MeasureGroup(TotalsMeasure.Use, "L", [31]), new MeasureGroup(TotalsMeasure.Use, "m³", [30])],
groups,
MeasureGroupComparer.Instance);
}
[Fact]
public void Links_across_energy_types_are_ignored()
{
var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Haus, Wasser)]);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
Assert.Empty(result.For(Wasser).ParentIds);
}
[Fact]
public void A_containment_cycle_is_reported_and_neither_meter_becomes_a_root()
{
List<TotalsMeter> meters =
[
Physical(30, "A", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "B", Water, MeterMode.CumulativeCounter, "m³"),
];
var result = TotalsPolicy.Classify(meters, [new(30, 31), new(31, 30)]);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(30).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(31).Class);
Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 30), result.Problems);
Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 31), result.Problems);
Assert.Equal(BillingBasis.None, result.ForType(Water).Billing.Basis);
}
[Fact]
public void A_total_load_linked_below_another_consumption_meter_is_reported_and_the_parent_is_not_counted()
{
var meters = Meters();
meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Contains(new TotalsProblem(TotalsProblemKind.TotalLoadIsContained, Haus, 10), result.Problems);
Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class);
Assert.Equal(MeterTotalsReason.ContainsTotalLoad, result.For(10).Reason);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void A_type_with_only_generation_bills_nothing()
{
var result = TotalsPolicy.Classify([Physical(40, "Balcony PV", Electricity, MeterMode.GenerationCounter, "kWh")], []);
Assert.Equal(BillingBasis.None, result.ForType(Electricity).Billing.Basis);
Assert.Equal([40], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Empty(result.BillItems);
var unknown = result.ForType(99);
Assert.Empty(unknown.Measures);
Assert.Equal(BillingBasis.None, unknown.Billing.Basis);
}
[Fact]
public void Validate_needs_a_known_meter_and_classify_rejects_duplicate_ids()
{
Assert.Throws<ArgumentException>(() => TotalsPolicy.Validate(Meters(), Links(), 404, TotalsOverride.Always));
Assert.Throws<ArgumentException>(() => TotalsPolicy.Classify([.. Meters(), Meters()[0]], Links()));
}
[Theory]
[InlineData(null, TotalsOverride.Auto)]
[InlineData("", TotalsOverride.Auto)]
[InlineData("auto", TotalsOverride.Auto)]
[InlineData("always", TotalsOverride.Always)]
[InlineData(" Never ", TotalsOverride.Never)]
[InlineData("sometimes", TotalsOverride.Auto)]
public void Override_tokens_parse_leniently_and_default_to_auto(string? token, TotalsOverride expected) =>
Assert.Equal(expected, TotalsOverrideTokens.Parse(token));
[Fact]
public void Override_is_read_from_meter_meta_and_round_trips_its_token()
{
Assert.Equal(TotalsOverride.Always, TotalsOverrideTokens.FromMeta("""{"role":"total_load","totals":"always"}"""));
Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("not json"));
Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("""{"totals":1}"""));
foreach (var value in Enum.GetValues<TotalsOverride>())
{
Assert.Equal(value, TotalsOverrideTokens.Parse(TotalsOverrideTokens.ToToken(value)));
}
}
[Fact]
public void Measures_group_units_by_their_canonical_spelling()
{
List<TotalsMeter> meters =
[
Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m3"),
Physical(31, "Well", Water, MeterMode.CumulativeCounter, "m³"),
];
var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use);
Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [30, 31])], groups, MeasureGroupComparer.Instance);
}
[Theory]
[InlineData(Netz)]
[InlineData(Solar1)]
public void Always_on_a_consumption_sum_over_a_supply_meter_is_refused_naming_it(int source)
{
// m(Netz) as consumption would add the grid's energy to household use, which Haus already holds; m(Solar 1)
// as consumption would add generation to use.
var meters = Meters();
meters.Add(Virtual(10, "Grid as use", Electricity, "kWh", QuantityKind.Consumption, [source], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherMeasure, source), check.Conflict);
meters[^1] = meters[^1] with { Override = TotalsOverride.Always };
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Always_on_a_virtual_without_a_declared_result_kind_is_not_additive()
{
// A legacy Summe Solar whose definition is not usable yet: its kind falls back to consumption in D-20, but that
// is a display assumption — counting it would add PV generation to household use.
var meters = MetersWith(SummeSolar, m => m with { Kind = QuantityKind.Consumption, VirtualResultKind = null, Override = TotalsOverride.Always });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), result.For(SummeSolar).RefusedOverride);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(
TotalsConflictReason.NotAdditive,
TotalsPolicy.Validate(MetersWith(SummeSolar, m => m with { VirtualResultKind = null }), Links(), SummeSolar, TotalsOverride.Always).Conflict!.Reason);
}
[Fact]
public void An_always_beside_a_counted_total_load_is_refused_naming_the_total_load()
{
// Main breaker → Haus and Main breaker → Workshop: the workshop is no subsection of Haus by link, but Haus is
// the total load — everything used is already in it, so counting the workshop as well double-counts.
var meters = Meters();
meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh"));
meters.Add(Physical(11, "Workshop", Electricity, MeterMode.CumulativeCounter, "kWh"));
List<TotalsLink> links = [.. Links(), new(10, Haus), new(10, 11)];
var check = TotalsPolicy.Validate(meters, links, 11, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void An_always_sum_over_meters_retired_before_the_total_load_was_installed_is_allowed()
{
// Nothing was inside the total load before it existed, so a sum over the older meters adds nothing twice.
var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) });
meters.Add(Physical(10, "Old house", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Physical(11, "Old annex", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Virtual(12, "Old total", Electricity, "kWh", QuantityKind.Consumption, [10, 11], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 12, TotalsOverride.Always);
Assert.True(check.IsAllowed);
}
[Fact]
public void Always_on_a_sum_listing_a_meter_twice_is_refused_as_overlapping()
{
var meters = Meters();
meters.Add(Virtual(10, "Solar 1 twice", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar1], pureSum: true, totals: TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Solar1), result.For(10).RefusedOverride);
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Always_on_nested_sums_sharing_a_meter_is_refused_as_overlapping()
{
// A = m30 + m31, B = m31 + m32, C = A + B: the expansion keeps multiplicity, so m31 appears twice.
List<TotalsMeter> meters =
[
Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
Physical(32, "Pool", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "C", Water, "m³", QuantityKind.Consumption, [30, 31, 31, 32], pureSum: true),
];
var check = TotalsPolicy.Validate(meters, [], 40, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, 31), check.Conflict);
}
[Fact]
public void Always_on_a_sum_with_a_source_in_another_energy_type_is_refused_naming_it()
{
// Counted in electricity as an m³ group, while water still counts and bills the same meter.
List<TotalsMeter> meters =
[
Physical(30, "Water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "Water in electricity", Electricity, "m³", QuantityKind.Consumption, [30], pureSum: true, totals: TotalsOverride.Always),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherEnergyType, 30), result.For(40).RefusedOverride);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([30], result.ForType(Water).Billing.BilledMeterIds);
}
[Fact]
public void Never_is_always_allowed_even_when_it_lets_a_lower_id_always_push_another_out()
{
// Water 30 and 31, meter 3 below 30 set to Always, and 40 = 30 + 31 set to Always. Today 3 is refused (30
// counts) and 40 replaces 30 and 31. Excluding 30 lets 3 apply first, which then overlaps 40. Excluding a meter
// never counts anything twice, so the save goes through and the displaced override is reported instead.
List<TotalsMeter> meters =
[
Physical(3, "Kitchen", Water, MeterMode.CumulativeCounter, "m³", totals: TotalsOverride.Always),
Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
];
List<TotalsLink> links = [new(30, 3)];
var check = TotalsPolicy.Validate(meters, links, 30, TotalsOverride.Never);
Assert.True(check.IsAllowed);
var result = TotalsPolicy.Classify([.. meters.Select(m => m.Id == 30 ? m with { Override = TotalsOverride.Never } : m)], links);
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(3).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, 3), result.For(40).RefusedOverride);
Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, 40, 3, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems);
}
[Fact]
public void A_retired_grid_meter_keeps_its_role_beside_its_replacement_and_both_are_billed()
{
// The grid meter was replaced by a new meter record on 31 Jan 2023. Both hold grid_import (A-07); each counts
// only within its own service period, so the bill before the replacement is not lost and the old meter's link
// into Haus stays a supply edge instead of turning Haus into a contained total load.
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(10, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31)));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Empty(result.Problems);
Assert.Empty(result.Hints);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.GridImport, result.For(10).Class);
Assert.Equal([Netz, 10], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Empty(result.For(Haus).ParentIds);
}
[Fact]
public void Grid_meters_in_service_at_the_same_time_are_duplicates_whatever_their_dates()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 3, 31) });
meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 1)));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason);
Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
}
[Fact]
public void An_unlinked_meter_from_before_the_total_load_was_installed_is_a_root_of_its_own()
{
// The house meter only exists since 2020; the old workshop meter was retired before, so no total load ever
// measured it and it counts in household use for its own period. A meter in service with the house meter is
// still taken to be inside it.
var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) });
meters.Add(Physical(10, "Old workshop", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(10).Reason);
Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(11).Reason);
Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Hints, h => h.MeterId == 10);
}
[Fact]
public void An_unlinked_meter_spanning_a_total_load_replacement_is_assumed_inside_both()
{
var meters = MetersWith(Haus, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(10, "Zähler Haus (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, installedAt: new DateOnly(2023, 1, 31)));
meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Netz, 10)]);
Assert.Equal([Haus, 10], result.For(11).ParentIds);
Assert.Equal(Haus, result.For(11).RelatedMeterId);
Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), TotalsPolicy.Validate(meters, [.. Links(), new(Netz, 10)], 11, TotalsOverride.Always).Conflict);
}
[Fact]
public void A_grid_meter_and_a_total_load_never_in_service_together_raise_no_overlap_hint()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2019, 12, 31) });
meters = [.. meters.Select(m => m.Id == Haus ? m with { InstalledAt = new DateOnly(2020, 1, 1) } : m)];
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList();
var result = TotalsPolicy.Classify(meters, links);
Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad);
}
private sealed class MeasureGroupComparer : IEqualityComparer<MeasureGroup>
{
public static readonly MeasureGroupComparer Instance = new();
public bool Equals(MeasureGroup? x, MeasureGroup? y) =>
x is not null && y is not null && x.Measure == y.Measure && x.Unit == y.Unit && x.MeterIds.SequenceEqual(y.MeterIds);
public int GetHashCode(MeasureGroup obj) => HashCode.Combine(obj.Measure, obj.Unit);
}
private sealed class EntryComparer : IEqualityComparer<MeterTotalsEntry>
{
public static readonly EntryComparer Instance = new();
public bool Equals(MeterTotalsEntry? x, MeterTotalsEntry? y) =>
x is not null && y is not null
&& (x.MeterId, x.EnergyTypeId, x.Class, x.Reason, x.Natural, x.Measure, x.RelatedMeterId)
== (y.MeterId, y.EnergyTypeId, y.Class, y.Reason, y.Natural, y.Measure, y.RelatedMeterId)
&& Equals(x.RefusedOverride, y.RefusedOverride)
&& x.ParentIds.SequenceEqual(y.ParentIds)
&& x.ReplacesIds.SequenceEqual(y.ReplacesIds);
public int GetHashCode(MeterTotalsEntry obj) => obj.MeterId;
}
}
+105
View File
@@ -0,0 +1,105 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The reference data's topology as the totals policy sees it (ReferenceDataImporter): five electricity meters
/// and the Summe Solar view, the water meter, and the oil tank with its burner. Ids follow the seed's creation
/// order; the energy types are electricity 1, water 2, heating oil 3.
/// </summary>
/// <remarks>
/// Roles are given as the stored tokens and turned into <see cref="TotalsMeter.Role"/> through
/// <see cref="MeterRoleRules.Effective(MeterMode, string?)"/>, exactly as the reader does (A-07) — so a test that stores
/// "GRID_IMPORT" or a role on a tank sees what production would.
/// </remarks>
internal static class TotalsSeed
{
public const int Electricity = 1;
public const int Water = 2;
public const int Oil = 3;
public const int Haus = 1;
public const int Netz = 2;
public const int Auto = 3;
public const int Solar1 = 4;
public const int Solar2 = 5;
public const int Wasser = 6;
public const int Oeltank = 7;
public const int Brenner = 8;
public const int SummeSolar = 9;
/// <summary>Solar 1 → Summe, Solar 2 → Summe, Netz → Haus, Summe → Haus, Haus → Auto.</summary>
public static List<TotalsLink> Links() =>
[
new(Solar1, SummeSolar),
new(Solar2, SummeSolar),
new(Netz, Haus),
new(SummeSolar, Haus),
new(Haus, Auto),
];
public static List<TotalsMeter> Meters() =>
[
Physical(Haus, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad),
Physical(Netz, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(Auto, "Zähler Auto", Electricity, MeterMode.CumulativeCounter, "kWh"),
Physical(Solar1, "Zähler Solar 1", Electricity, MeterMode.GenerationCounter, "kWh"),
Physical(Solar2, "Zähler Solar 2", Electricity, MeterMode.GenerationCounter, "kWh"),
Physical(Wasser, "Zähler Wasser", Water, MeterMode.CumulativeCounter, "m³"),
Physical(Oeltank, "Öltank", Oil, MeterMode.ConsumableBalance, "L"),
Physical(Brenner, "Brenner", Oil, MeterMode.RuntimeCounter, "h"),
Virtual(SummeSolar, "Summe Solar", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true),
];
/// <summary>The seed with <paramref name="adjust"/> applied to the meter <paramref name="id"/>.</summary>
public static List<TotalsMeter> MetersWith(int id, Func<TotalsMeter, TotalsMeter> adjust) =>
[.. Meters().Select(m => m.Id == id ? adjust(m) : m)];
public static List<TotalsMeter> MetersWithOverride(params (int Id, TotalsOverride Override)[] overrides) =>
[.. Meters().Select(m => overrides.Any(o => o.Id == m.Id) ? m with { Override = overrides.First(o => o.Id == m.Id).Override } : m)];
/// <param name="role">The stored role token; the meter gets the effective role, as the reader gives it.</param>
public static TotalsMeter Physical(
int id,
string name,
int energyTypeId,
MeterMode mode,
string unit,
string? role = null,
TotalsOverride totals = TotalsOverride.Auto,
QuantityKind? kind = null,
DateOnly? installedAt = null,
DateOnly? retiredAt = null)
{
var effective = MeterRoleRules.Effective(mode, role);
return new(id, name, energyTypeId, mode, effective, kind ?? KindOf(mode, effective), unit, false, totals, installedAt, retiredAt,
[], false, null);
}
/// <param name="resultKind">The declared result kind; null for a definition that is not usable.</param>
/// <param name="role">A stored role token. A virtual meter never holds one (A-07), so it always reads as none.</param>
public static TotalsMeter Virtual(
int id,
string name,
int energyTypeId,
string unit,
QuantityKind? resultKind,
IReadOnlyList<int> sources,
bool pureSum,
string? role = null,
TotalsOverride totals = TotalsOverride.Auto) =>
new(id, name, energyTypeId, MeterMode.Virtual, MeterRoleRules.Effective(MeterMode.Virtual, role),
resultKind ?? QuantityKind.Consumption, unit, true, totals, null, null, sources, pureSum, resultKind);
// Mirrors NormalizedQuantity: an effective grid_export role makes a flow meter measure export.
private static QuantityKind KindOf(MeterMode mode, MeterRole? role) => mode switch
{
MeterMode.GenerationCounter => QuantityKind.Generation,
MeterMode.RuntimeCounter => QuantityKind.Runtime,
_ when role == MeterRole.GridExport => QuantityKind.Export,
_ => QuantityKind.Consumption,
};
}
+349
View File
@@ -0,0 +1,349 @@
using MeterVault.Core.Analysis.Quantities;
namespace MeterVault.Core.Tests.Analysis;
public sealed class UnitsTests
{
public static TheoryData<string, string, bool> AliasTableEntries
{
get
{
var data = new TheoryData<string, string, bool>();
foreach (var entry in Units.AliasTable)
{
data.Add(entry.Alias, entry.Info.Symbol, entry.CaseSensitive);
}
return data;
}
}
[Theory]
// Energy
[InlineData("kWh", "kWh")]
[InlineData("kwh", "kWh")]
[InlineData("KWH", "kWh")]
[InlineData("KWh", "kWh")]
[InlineData(" kWh ", "kWh")]
[InlineData("k Wh", "kWh")]
[InlineData("Kilowattstunden", "kWh")]
[InlineData("kilowatt-hour", "kWh")]
[InlineData("Kilowatt hours", "kWh")]
[InlineData("Wh", "Wh")]
[InlineData("wh", "Wh")]
[InlineData("Wattstunden", "Wh")]
[InlineData("MWh", "MWh")]
[InlineData("MWH", "MWh")]
[InlineData("Mwh", "MWh")]
[InlineData("Megawattstunde", "MWh")]
[InlineData("mWh", "mWh")]
[InlineData("GWh", "GWh")]
[InlineData("MJ", "MJ")]
[InlineData("gj", "GJ")]
// Power
[InlineData("W", "W")]
[InlineData("w", "W")]
[InlineData("Watt", "W")]
[InlineData("kW", "kW")]
[InlineData("kw", "kW")]
[InlineData("KW", "kW")]
[InlineData("Kilowatt", "kW")]
[InlineData("MW", "MW")]
[InlineData("Mw", "MW")]
[InlineData("mW", "mW")]
// Volume
[InlineData("m3", "m³")]
[InlineData("M3", "m³")]
[InlineData("m³", "m³")]
[InlineData("cbm", "m³")]
[InlineData("CBM", "m³")]
[InlineData("m^3", "m³")]
[InlineData("Kubikmeter", "m³")]
[InlineData("cubic metre", "m³")]
[InlineData("l", "L")]
[InlineData("L", "L")]
[InlineData("Liter", "L")]
[InlineData("liter", "L")]
[InlineData("Litre", "L")]
[InlineData("litres", "L")]
[InlineData("ltr", "L")]
[InlineData("dm3", "L")]
[InlineData("hl", "hL")]
[InlineData("Hektoliter", "hL")]
// Time
[InlineData("h", "h")]
[InlineData("H", "h")]
[InlineData("hour", "h")]
[InlineData("hours", "h")]
[InlineData("hrs", "h")]
[InlineData("Std", "h")]
[InlineData("Std.", "h")]
[InlineData("Stunden", "h")]
[InlineData("Betriebsstunden", "h")]
[InlineData("min", "min")]
[InlineData("Minuten", "min")]
[InlineData("s", "s")]
// Mass
[InlineData("kg", "kg")]
[InlineData("KG", "kg")]
[InlineData("t", "t")]
[InlineData("Tonne", "t")]
public void Every_alias_normalizes_to_its_canonical_symbol(string alias, string canonical)
{
Assert.Equal(canonical, Units.Normalize(alias));
}
[Theory]
[MemberData(nameof(AliasTableEntries))]
public void Every_entry_of_the_alias_table_names_its_own_unit_however_it_is_capitalised(
string alias, string symbol, bool caseSensitive)
{
var info = Units.Describe(symbol);
Assert.NotNull(info);
Assert.Equal(symbol, info.Symbol);
Assert.Equal(symbol, Units.Normalize(symbol));
Assert.Equal(symbol, Units.Normalize(alias));
Assert.Equal(info, Units.Describe(alias));
Assert.Equal(info, Units.Describe($" {alias} "));
if (!caseSensitive)
{
// Typing a unit in capitals never changes what it means ("KWH", "LITER", "M³").
Assert.Equal(info, Units.Describe(alias.ToUpperInvariant()));
}
}
[Fact]
public void The_alias_table_holds_every_canonical_symbol_once_per_spelling()
{
var spellings = Units.AliasTable.Select(a => a.Alias).ToList();
Assert.Equal(spellings.Count, spellings.Distinct(StringComparer.Ordinal).Count());
Assert.All(
new[] { "mWh", "Wh", "kWh", "MWh", "GWh", "MJ", "GJ", "mW", "W", "kW", "MW", "GW", "L", "hL", "m³", "g", "kg", "t", "h", "min", "s" },
symbol => Assert.Contains(symbol, spellings));
}
[Fact]
public void Milli_and_mega_are_told_apart_by_the_case_of_the_m()
{
// Home Assistant reports small sensors in mW/mWh; reading them as MW/MWh would be off by 10⁹.
Assert.Equal(new UnitInfo("mW", UnitDimension.Power, 0.000_001), Units.Describe("mW"));
Assert.Equal(new UnitInfo("MW", UnitDimension.Power, 1_000), Units.Describe("MW"));
Assert.Equal(new UnitInfo("mWh", UnitDimension.Energy, 0.000_001), Units.Describe("mWh"));
Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1_000), Units.Describe("MWh"));
Assert.Equal(0.000_001, Units.ConversionFactor("mWh", "kWh")!.Value, 12);
Assert.False(Units.AreSame("mW", "MW"));
}
[Theory]
[InlineData("mwh")]
[InlineData("mw")]
[InlineData("mj")]
[InlineData("mWH")]
public void A_lower_case_m_on_a_mega_symbol_is_not_guessed(string unit)
{
Assert.Null(Units.Describe(unit));
Assert.Null(Units.ConversionFactor(unit, "kWh"));
Assert.Null(Units.ConversionFactor(unit, "kW"));
}
[Theory]
[InlineData(" Stk ", "stk")]
[InlineData("STK", "stk")]
[InlineData("Pellets", "pellets")]
[InlineData("%", "%")]
[InlineData("100 L", "100 l")]
[InlineData("kWh (el)", "kwh (el)")]
[InlineData("Nm³", "nm³")]
public void An_unknown_unit_is_trimmed_and_folded_so_every_spelling_gives_one_key(string unit, string expected)
{
Assert.Equal(expected, Units.Normalize(unit));
Assert.Null(Units.Describe(unit));
}
[Fact]
public void Normalized_units_can_be_grouped_ordinally()
{
var meters = new[] { "Stk", "stk", "STK", "m3", "m³", "cbm", "kWh", "KWH" };
var groups = meters.GroupBy(Units.Normalize, StringComparer.Ordinal).Select(g => (g.Key, g.Count())).ToList();
Assert.Equal([("stk", 3), ("m³", 3), ("kWh", 2)], groups);
}
[Fact]
public void The_unit_comparer_treats_every_spelling_of_a_unit_as_one_key()
{
var comparer = Units.Comparer;
Assert.True(comparer.Equals("m3", "m³"));
Assert.True(comparer.Equals("Stk", "stk"));
Assert.True(comparer.Equals(null, " "));
Assert.False(comparer.Equals("kWh", "MWh"));
Assert.False(comparer.Equals("mW", "MW"));
Assert.Equal(comparer.GetHashCode("Stk"), comparer.GetHashCode("STK"));
Assert.Equal(comparer.GetHashCode("m3"), comparer.GetHashCode("cbm"));
Assert.Equal(3, new[] { "m3", "m³", "Stk", "stk", "kWh", "kwh" }.Distinct(comparer).Count());
var byUnit = new Dictionary<string, int>(comparer) { ["m3"] = 1 };
Assert.True(byUnit.ContainsKey("Kubikmeter"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_missing_unit_normalizes_to_empty(string? unit)
{
Assert.Equal(string.Empty, Units.Normalize(unit));
}
[Theory]
[InlineData("m3/h", "m³/h")]
[InlineData("l/h", "L/h")]
[InlineData("Liter / Std", "L/h")]
[InlineData("kWh / h", "kWh/h")]
[InlineData("Stk/h", "stk/h")]
public void A_rate_normalizes_both_sides(string unit, string expected)
{
Assert.Equal(expected, Units.Normalize(unit));
}
[Theory]
[InlineData("m3", "m³")]
[InlineData("cbm", "M3")]
[InlineData("Liter", "l")]
[InlineData("Stk", "stk")]
[InlineData("L/h", "l/Std")]
[InlineData("Kilowattstunden", "kWh")]
public void Different_spellings_of_one_unit_are_the_same(string a, string b)
{
Assert.True(Units.AreSame(a, b));
}
[Fact]
public void Units_that_only_convert_into_each_other_are_not_the_same()
{
Assert.False(Units.AreSame("kWh", "MWh"));
Assert.False(Units.AreSame("L", "m³"));
}
[Theory]
[InlineData("kWh", "MWh")]
[InlineData("Wh", "kWh")]
[InlineData("L", "m3")]
[InlineData("hl", "L")]
[InlineData("kW", "MW")]
[InlineData("h", "min")]
[InlineData("kg", "t")]
[InlineData("GJ", "kWh")]
[InlineData("Stk", "Stk")]
[InlineData("m3/h", "L/min")]
[InlineData("mWh", "kWh")]
public void Units_of_one_dimension_are_compatible(string a, string b)
{
Assert.True(Units.AreCompatible(a, b));
Assert.True(Units.AreCompatible(b, a));
}
[Theory]
[InlineData("kWh", "m³")]
[InlineData("kW", "kWh")]
[InlineData("h", "L")]
[InlineData("kg", "L")]
[InlineData("Stk", "kWh")]
[InlineData("Stk", "Pellets")]
[InlineData("", "kWh")]
[InlineData("", "")]
[InlineData("m³/h", "m³")]
public void Units_of_different_dimensions_are_not_compatible(string a, string b)
{
Assert.False(Units.AreCompatible(a, b));
Assert.Null(Units.ConversionFactor(a, b));
}
[Theory]
[InlineData("MWh", "kWh", 1000)]
[InlineData("kWh", "MWh", 0.001)]
[InlineData("Wh", "kWh", 0.001)]
[InlineData("kWh", "kWh", 1)]
[InlineData("m3", "L", 1000)]
[InlineData("L", "m³", 0.001)]
[InlineData("hL", "L", 100)]
[InlineData("L", "hL", 0.01)]
[InlineData("t", "kg", 1000)]
[InlineData("min", "h", 1 / 60d)]
[InlineData("GJ", "kWh", 1000 / 3.6)]
[InlineData("MJ", "kWh", 1 / 3.6)]
[InlineData("Stk", "stk", 1)]
[InlineData("m³/h", "L/min", 1000 / 60d)]
[InlineData("Kilowattstunden", "Wh", 1000)]
public void The_conversion_factor_expresses_an_amount_in_the_target_unit(string from, string to, double factor)
{
Assert.Equal(factor, Units.ConversionFactor(from, to)!.Value, 9);
}
[Theory]
[InlineData("W", "Wh")]
[InlineData("kW", "kWh")]
[InlineData("kw", "kWh")]
[InlineData("MW", "MWh")]
[InlineData("GW", "GWh")]
[InlineData("mW", "mWh")]
[InlineData("L/h", "L")]
[InlineData("m3/h", "m³")]
[InlineData("l/Std", "L")]
[InlineData("kWh/h", "kWh")]
[InlineData("W/m²", "Wh/m²")]
[InlineData("kW/m2", "kWh/m2")]
[InlineData("kWh", "kWh")]
[InlineData("L", "L")]
[InlineData("Stk", "stk")]
[InlineData("", "")]
public void A_rate_per_hour_integrates_over_hours_to_its_quantity_unit(string rateUnit, string integrated)
{
Assert.Equal(integrated, Units.IntegratedOverHours(rateUnit));
}
[Theory]
[InlineData("L/min", "L/min")]
[InlineData("l/Minute", "L/min")]
[InlineData("m³/s", "m³/s")]
[InlineData("L/Tag", "L/tag")]
public void A_rate_over_another_time_keeps_its_rate_unit_because_the_normalizer_does_not_rescale_it(
string rateUnit, string kept)
{
// The normalizer books value × elapsed hours: 10 L/min for an hour books 10, which is not 10 L.
Assert.Equal(kept, Units.IntegratedOverHours(rateUnit));
Assert.Null(Units.ConversionFactor(Units.IntegratedOverHours(rateUnit), "L"));
}
[Theory]
[InlineData("kW", true)]
[InlineData("W", true)]
[InlineData("mW", true)]
[InlineData("L/h", true)]
[InlineData("m3/Std", true)]
[InlineData("W/m²", true)]
[InlineData("L/min", false)]
[InlineData("m³/s", false)]
[InlineData("kWh", false)]
[InlineData("Stk", false)]
[InlineData("", false)]
public void Only_power_and_explicit_per_hour_units_are_stated_per_hour_rates(string rateUnit, bool perHour)
{
Assert.Equal(perHour, Units.IsPerHourRate(rateUnit));
}
[Fact]
public void Describe_reports_dimension_and_scale_against_the_reference_unit()
{
var mwh = Units.Describe("MWH")!;
var cubic = Units.Describe("cbm")!;
Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1000), mwh);
Assert.Equal(new UnitInfo("m³", UnitDimension.Volume, 1000), cubic);
Assert.Null(Units.Describe("L/h"));
}
}
@@ -0,0 +1,250 @@
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A virtual meter's definition lives in <c>Meter.Meta</c> next to other keys such as <c>role</c> (D-25). Writing it
/// must never lose those keys, the referenced ids must follow the expression rather than whatever was stored, and
/// reading must never throw — a bad blob becomes a "malformed" result for that one meter.
/// </summary>
public sealed class VirtualDefinitionJsonTests
{
private static readonly VirtualDefinition SummeSolar = new("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None);
[Fact]
public void A_definition_round_trips_and_keeps_the_role_and_every_other_key()
{
const string existing = """{"role":"grid_import","custom":{"a":[1,2]},"note":"keep me"}""";
var written = VirtualDefinitionJson.Write(existing, SummeSolar);
var read = VirtualDefinitionJson.Read(written);
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.Equal(SummeSolar, read.Definition);
Assert.False(read.ReferencedIdsStale);
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(written));
Assert.Equal("keep me", MeterMeta.ReadString(written, "note"));
using var doc = JsonDocument.Parse(written);
Assert.Equal("[1,2]", doc.RootElement.GetProperty("custom").GetProperty("a").GetRawText());
}
[Fact]
public void Written_keys_use_the_documented_tokens_and_derive_the_referenced_ids()
{
using var doc = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", SummeSolar));
var root = doc.RootElement;
Assert.Equal("m4 + m5", root.GetProperty("expression").GetString());
Assert.Equal("[4,5]", root.GetProperty("referencedMeterIds").GetRawText());
Assert.Equal("generation", root.GetProperty("resultKind").GetString());
Assert.Equal("kWh", root.GetProperty("resultUnit").GetString());
Assert.Equal("none", root.GetProperty("costRule").GetString());
using var consumption = JsonDocument.Parse(VirtualDefinitionJson.Write(
"{}", new VirtualDefinition("m1 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts)));
Assert.Equal("sourceCosts", consumption.RootElement.GetProperty("costRule").GetString());
}
[Fact]
public void Writing_a_definition_that_leaves_its_kind_or_cost_rule_to_inference_is_refused()
{
// A-08: what is stored is the effective definition. Storing "m4 + m5" without its kind would make every reader
// take Summe Solar for consumption unless it re-validated the whole catalog first.
const string meta = """{"role":"total_load"}""";
Assert.Throws<ArgumentException>(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2")));
Assert.Throws<ArgumentException>(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2", QuantityKind.Consumption)));
Assert.Throws<ArgumentException>(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m9", QuantityKind.Runtime, "h", VirtualCostRule.None)));
}
[Fact]
public void Saving_stores_the_inferred_kind_unit_and_cost_rule_so_readers_never_infer_them_again()
{
var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity());
var validation = VirtualValidator.Validate(new VirtualDefinition("m4 + m5"), 6, catalog);
var read = VirtualDefinitionJson.Read(VirtualDefinitionJson.Write("""{"note":"x"}""", validation.EffectiveDefinition!));
Assert.Equal(SummeSolar, read.Definition);
var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, read.Definition!.DeclaredResult);
Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity);
}
[Fact]
public void The_unit_is_stored_in_canonical_spelling_and_a_blank_one_is_left_out()
{
using var water = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", new VirtualDefinition("m7", QuantityKind.Consumption, " m3 ", VirtualCostRule.None)));
Assert.Equal("m³", water.RootElement.GetProperty("resultUnit").GetString());
using var unitless = JsonDocument.Parse(VirtualDefinitionJson.Write("""{"resultUnit":"kWh"}""", new VirtualDefinition("m7", QuantityKind.Consumption, null, VirtualCostRule.None)));
Assert.False(unitless.RootElement.TryGetProperty("resultUnit", out _));
}
[Theory]
[InlineData("runtime")]
[InlineData("export")]
[InlineData("cost")]
public void A_stored_kind_no_virtual_meter_may_have_is_malformed(string token)
{
var read = VirtualDefinitionJson.Read($$"""{"expression":"m1","resultKind":"{{token}}"}""");
Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status);
Assert.Null(read.Definition!.ResultKind);
}
[Theory]
[InlineData("expression", "LONE")]
[InlineData("resultUnit", "LONEx")]
[InlineData("costRule", "LONE")]
public void Text_that_cannot_be_decoded_is_malformed_not_an_exception(string key, string value)
{
// A lone surrogate escape is valid JSON but no .NET string; jsonb refuses it, an import file does not.
var escaped = value.Replace("LONE", "\\ud800", StringComparison.Ordinal);
var meta = key == "expression"
? $$"""{"expression":"{{escaped}}"}"""
: $$"""{"expression":"m1","{{key}}":"{{escaped}}"}""";
Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(meta).Status);
}
[Fact]
public void Stored_referenced_ids_are_never_trusted_over_the_expression()
{
var read = VirtualDefinitionJson.Read("""{"expression":"m1 + m2","referencedMeterIds":[9]}""");
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.Equal([1, 2], read.Definition!.ReferencedMeterIds);
Assert.True(read.ReferencedIdsStale);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
[InlineData("""{"role":"grid_import"}""")]
[InlineData("""{"expression":null}""")]
[InlineData("""{"expression":" "}""")]
public void Meta_without_an_expression_is_a_legacy_meter_not_an_error(string? meta)
{
var read = VirtualDefinitionJson.Read(meta);
Assert.Equal(VirtualDefinitionReadStatus.Absent, read.Status);
Assert.Null(read.Definition);
}
[Theory]
[InlineData("not json")]
[InlineData("{\"expression\":")]
[InlineData("[1,2,3]")]
[InlineData("\"m1 + m2\"")]
[InlineData("""{"expression":5}""")]
[InlineData("""{"expression":["m1"]}""")]
public void Unreadable_meta_is_malformed_and_never_throws(string meta)
{
var read = VirtualDefinitionJson.Read(meta);
Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status);
Assert.NotNull(read.Problem);
}
[Fact]
public void Json_nested_beyond_the_reader_depth_is_malformed_not_an_exception()
{
var deep = """{"expression":"m1","x":""" + new string('[', 200) + new string(']', 200) + "}";
Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(deep).Status);
}
[Theory]
[InlineData("""{"expression":"m1","resultKind":"power"}""")]
[InlineData("""{"expression":"m1","resultKind":3}""")]
[InlineData("""{"expression":"m1","costRule":"cheap"}""")]
[InlineData("""{"expression":"m1","resultUnit":true}""")]
public void A_bad_optional_key_is_malformed_but_keeps_the_expression_for_repair(string meta)
{
var read = VirtualDefinitionJson.Read(meta);
Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status);
Assert.Equal("m1", read.Definition!.Expression);
}
[Fact]
public void A_syntax_error_is_the_validators_business_not_a_json_problem()
{
var read = VirtualDefinitionJson.Read("""{"expression":"m1 +"}""");
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.False(read.Definition!.Parsed.Success);
Assert.Equal([1], read.Definition.ReferencedMeterIds);
}
[Fact]
public void Tokens_are_read_case_insensitively()
{
var read = VirtualDefinitionJson.Read("""{"expression":"m1","resultKind":"Generation","costRule":"SOURCECOSTS","resultUnit":" kWh "}""");
Assert.Equal(new VirtualDefinition("m1", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts), read.Definition);
}
[Fact]
public void Rewriting_meter_ids_follows_an_import_renumbering_and_keeps_the_role()
{
const string meta = """{"role":"total_load","expression":"m1 - m2","referencedMeterIds":[1,2],"resultKind":"consumption"}""";
var map = new Dictionary<int, int> { [1] = 11, [2] = 12 };
var rewritten = VirtualDefinitionJson.RewriteMeterIds(meta, id => map[id]);
var read = VirtualDefinitionJson.Read(rewritten);
Assert.Equal("m11 - m12", read.Definition!.Expression);
Assert.Equal([11, 12], read.Definition.ReferencedMeterIds);
Assert.False(read.ReferencedIdsStale);
Assert.Equal(QuantityKind.Consumption, read.Definition.ResultKind);
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(rewritten));
}
[Theory]
[InlineData("""{"role":"grid_import"}""")]
[InlineData("not json")]
[InlineData("")]
public void Rewriting_meter_ids_leaves_meta_without_an_expression_untouched(string meta)
{
Assert.Equal(meta, VirtualDefinitionJson.RewriteMeterIds(meta, _ => throw new InvalidOperationException("no ids to map")));
}
[Fact]
public void Removing_a_definition_keeps_every_other_key()
{
var withDefinition = VirtualDefinitionJson.Write("""{"role":"grid_export"}""", SummeSolar);
var removed = VirtualDefinitionJson.Remove(withDefinition);
Assert.Equal(VirtualDefinitionReadStatus.Absent, VirtualDefinitionJson.Read(removed).Status);
Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(removed));
}
[Theory]
[InlineData("[1,2]")]
[InlineData("not json")]
[InlineData("""{"a":1,"a":2}""")]
public void Meta_that_is_not_a_json_object_is_replaced_on_write(string meta)
{
var written = VirtualDefinitionJson.Write(meta, SummeSolar);
Assert.Equal(SummeSolar, VirtualDefinitionJson.Read(written).Definition);
}
[Fact]
public void Changing_the_expression_with_a_with_expression_reparses_it()
{
var changed = SummeSolar with { Expression = "m7 * 2" };
Assert.Equal([7], changed.ReferencedMeterIds);
Assert.Equal(Formula.Parse("m7 * 2"), changed.Formula);
Assert.Equal([4, 5], SummeSolar.ReferencedMeterIds);
}
}
@@ -0,0 +1,516 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Virtual meters evaluated on read (D-27), pinned on the brief's worked example (§5.4): A and B are generation
/// meters with complete monthly data — A 100/80 kWh, B 150/120 kWh in January/February. A missing source makes a
/// bucket unknown, never a confident partial number; an observed zero is a real input; non-finite arithmetic is
/// invalid, never zero; a non-additive formula's total is the formula over the totals.
/// </summary>
public sealed class VirtualEvaluatorTests
{
/// <summary>The virtual meter being evaluated; every dependency path starts here.</summary>
private const int Self = 99;
private const int A = 1;
private const int B = 2;
private static readonly AnalysisBucket[] JanFeb = Months(2025, 1, 2);
private static VirtualSource SourceA() => Source(A, Monthly((2025, 1, 100), (2025, 2, 80)));
private static VirtualSource SourceB() => Source(B, Monthly((2025, 1, 150), (2025, 2, 120)));
private static VirtualEvaluation Evaluate(string formula, params VirtualSource[] sources) =>
Evaluate(formula, JanFeb, sources);
private static VirtualEvaluation Evaluate(
string formula, IReadOnlyList<AnalysisBucket> buckets, IEnumerable<VirtualSource> sources, int meterId = Self, QuantityKind kind = QuantityKind.Generation) =>
VirtualEvaluator.Evaluate(meterId, Formula.Parse(formula), kind, buckets, sources);
private static BucketValue Unresolved(double? value = null) =>
new(value, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution);
[Fact]
public void A_plus_B_is_250_and_200_by_month_and_450_in_total()
{
var result = Evaluate("m1 + m2", SourceA(), SourceB());
Assert.Equal([250d, 200d], result.Values.Select(v => v.Value!.Value));
Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status));
Assert.Equal(450, result.Total.Value);
Assert.Equal(BucketStatus.Available, result.Total.Status);
Assert.True(result.IsAdditive);
Assert.Equal(Provenance.Derived | Provenance.Imported, result.Total.Provenance);
Assert.Equal(59, result.JointDays);
Assert.Equal(ResolutionClass.Month, result.Resolution);
Assert.Equal(Self, result.MeterId);
}
[Fact]
public void Each_source_contributes_its_own_series_and_the_amounts_that_entered_the_formula()
{
var result = Evaluate("m1 + m2", SourceA(), SourceB());
var a = result.Contributions.Single(c => c.MeterId == A);
var b = result.Contributions.Single(c => c.MeterId == B);
Assert.Equal([100d, 80d], a.Values.Select(v => v.Value!.Value));
Assert.Equal([150d, 120d], b.Values.Select(v => v.Value!.Value));
Assert.Equal([100d, 80d], a.UsedAmounts.Select(v => v!.Value));
Assert.Equal(1, a.Coefficient);
Assert.Equal(180, a.Total.Value);
Assert.Equal(270, b.UsedTotal);
}
[Fact]
public void B_missing_in_February_makes_February_missing_not_a_confident_80()
{
var bOnlyJanuary = Source(B, Monthly((2025, 1, 150)));
var result = Evaluate("m1 + m2", SourceA(), bOnlyJanuary);
var february = result.Values[1];
Assert.Equal(BucketStatus.Missing, february.Status);
Assert.Null(february.Value);
Assert.Equal(ValueIssue.MissingSource, february.Issue);
Assert.Equal([Self, B], february.DependencyPath!);
Assert.Null(february.IssueDetail);
// The period total is the formula over the joint coverage — January only — and says so. A's February is a
// whole month outside the joint coverage, not a month cut in two, so the total stays a partial 250.
Assert.Equal(BucketStatus.Partial, result.Total.Status);
Assert.Equal(250, result.Total.Value);
Assert.Equal(new DateOnly(2025, 1, 31), result.LastJointDay);
// The contribution table still shows A's own February and B's absence.
var a = result.Contributions.Single(c => c.MeterId == A);
var b = result.Contributions.Single(c => c.MeterId == B);
Assert.Equal(80, a.Values[1].Value);
Assert.Equal(BucketStatus.Missing, b.Values[1].Status);
Assert.Null(a.UsedAmounts[1]);
}
[Fact]
public void B_observed_zero_in_February_makes_a_complete_80()
{
var bZeroInFebruary = Source(B, Monthly((2025, 1, 150), (2025, 2, 0)));
var result = Evaluate("m1 + m2", SourceA(), bZeroInFebruary);
Assert.Equal(BucketValue.Available(80, Provenance.Derived | Provenance.Imported), result.Values[1]);
Assert.Equal(330, result.Total.Value);
Assert.Equal(BucketStatus.Available, result.Total.Status);
}
[Fact]
public void A_minus_B_is_minus_50_and_minus_40_and_stays_signed()
{
var result = Evaluate("m1 - m2", SourceA(), SourceB());
Assert.Equal([-50d, -40d], result.Values.Select(v => v.Value!.Value));
Assert.Equal(-90, result.Total.Value);
Assert.True(result.IsAdditive);
Assert.Equal(-1, result.Contributions.Single(c => c.MeterId == B).Coefficient);
}
[Fact]
public void A_source_covering_part_of_a_bucket_makes_it_partial_with_the_jointly_covered_value()
{
var feb1 = new DateOnly(2025, 2, 1);
var dailyA = Source(A, Daily(feb1, feb1.AddMonths(1), 1));
var halfB = Source(B, Daily(feb1, feb1.AddDays(14), 2));
var result = Evaluate("m1 + m2", [Month(2025, 2)], [dailyA, halfB]);
var february = Assert.Single(result.Values);
Assert.Equal(BucketStatus.Partial, february.Status);
Assert.Equal(14 + 28, february.Value);
Assert.Equal(ValueIssue.PartialCoverage, february.Issue);
Assert.Equal([Self, B], february.DependencyPath!);
Assert.Equal(ResolutionClass.Day, result.Resolution);
}
[Theory]
[InlineData(10, 22)] // m2 covers 10–31 January: a full month of m1 against 22 days of m2 would read 78
[InlineData(1, 15)] // m2 covers 1–15 January: m1's amount lies outside the joint days, so it would read −15
public void A_monthly_source_cut_inside_its_month_by_another_sources_coverage_is_unresolved_not_a_wrong_partial(int firstDay, int dayCount)
{
var from = new DateOnly(2025, 1, firstDay);
var monthly = Source(A, Monthly((2025, 1, 100)));
var daily = Source(B, Daily(from, from.AddDays(dayCount), 1));
var result = Evaluate("m1 - m2", [Month(2025, 1)], [monthly, daily]);
foreach (var value in new[] { result.Values[0], result.Total })
{
Assert.Equal(BucketStatus.Unresolved, value.Status);
Assert.Equal(ValueIssue.CoarseResolution, value.Issue);
Assert.Null(value.Value);
Assert.Equal([Self, A], value.DependencyPath!);
}
Assert.Null(result.Contributions.Single(c => c.MeterId == A).UsedAmounts[0]);
}
[Theory]
[InlineData(true, BucketStatus.Partial)]
[InlineData(false, BucketStatus.Unresolved)]
public void A_monthly_source_may_be_cut_at_a_month_boundary_only_when_it_is_divided_at_months(bool dividedAtMonths, BucketStatus expected)
{
// m1 is monthly for January and February, m2 starts on 1 February. Divided at months, m1's February is exactly
// February's use and the two-month bucket is a partial 80 − 28. A tank read mid-month is not: its "February"
// amount belongs to an interval that starts in January, so the same cut is unresolved.
var monthly = Source(A, Monthly(dividedAtMonths, (2025, 1, 100), (2025, 2, 80)));
var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 3, 1), 1));
var janFeb = new AnalysisBucket(new DateOnly(2025, 1, 1), new DateOnly(2025, 3, 1), Utc(new DateOnly(2025, 1, 1)), Utc(new DateOnly(2025, 3, 1)), BucketSize.Year);
var result = Evaluate("m1 - m2", [janFeb], [monthly, daily]);
Assert.Equal(expected, result.Values[0].Status);
Assert.Equal(expected, result.Total.Status);
if (expected == BucketStatus.Partial)
{
Assert.Equal(80 - 28, result.Values[0].Value);
Assert.Equal([Self, B], result.Values[0].DependencyPath!);
}
else
{
Assert.Equal([Self, A], result.Values[0].DependencyPath!);
}
}
[Fact]
public void A_quarterly_interval_cut_by_joint_coverage_leaves_the_period_total_unresolved()
{
// m1 books one quarterly reading (1 Jan – 31 Mar, 300) and reports its month buckets unresolved while resolving
// the quarter. m2 only starts in February, so the joint coverage keeps all 300 against two months of m2.
var quarter = Source(A, Coarse(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 300)) with
{
BucketStates = [Unresolved(), Unresolved(), Unresolved()],
PeriodState = BucketValue.Available(300, Provenance.Imported),
};
var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 4, 1), 1));
var result = Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, daily]);
Assert.Equal(BucketStatus.Unresolved, result.Total.Status);
Assert.Equal([Self, A], result.Total.DependencyPath!);
// Covering the whole quarter, m2 no longer cuts it: the total resolves.
var wholeQuarter = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 1));
Assert.Equal(300 - 90, Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, wholeQuarter]).Total.Value);
}
[Fact]
public void A_ratio_is_non_additive_and_its_total_is_the_ratio_of_the_totals()
{
var a = Source(A, Monthly((2025, 1, 100), (2025, 2, 80)));
var b = Source(B, Monthly((2025, 1, 50), (2025, 2, 20)));
var result = Evaluate("m1 / m2", a, b);
Assert.Equal([2d, 4d], result.Values.Select(v => v.Value!.Value));
Assert.False(result.IsAdditive);
Assert.Equal(180d / 70d, result.Total.Value!.Value, 12); // not 2 + 4
}
[Fact]
public void An_indicator_is_never_additive_even_with_a_linear_formula()
{
var result = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Indicator);
Assert.False(result.IsAdditive);
Assert.False(VirtualSource.FromEvaluation(result).IsAdditive);
Assert.True(Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Net).IsAdditive);
}
[Fact]
public void Division_by_zero_makes_the_bucket_invalid_with_the_reason_never_zero_or_infinity()
{
var bZeroInFebruary = Source(B, Monthly((2025, 1, 50), (2025, 2, 0)));
var result = Evaluate("m1 / m2", SourceA(), bZeroInFebruary);
Assert.Equal(2, result.Values[0].Value);
var february = result.Values[1];
Assert.Equal(BucketStatus.Invalid, february.Status);
Assert.Equal(ValueIssue.NonFinite, february.Issue);
Assert.Null(february.Value);
Assert.Equal([80d, 0d], result.Contributions.Select(c => c.UsedAmounts[1]!.Value));
Assert.Equal(180d / 50d, result.Total.Value!.Value, 12);
}
[Fact]
public void A_meter_outside_its_lifetime_contributes_a_known_zero()
{
// A retired at the end of June, its successor C installed on 1 July — no swap event joins them.
var retired = Source(A, Monthly([.. Enumerable.Range(1, 6).Select(m => (2024, m, 10d * m))])) with { RetiredAt = new DateOnly(2024, 6, 30) };
var successor = Source(3, Monthly([.. Enumerable.Range(7, 6).Select(m => (2024, m, 100d + m))])) with { InstalledAt = new DateOnly(2024, 7, 1) };
var result = Evaluate("m1 + m3", Months(2024, 1, 12), [retired, successor]);
Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status));
Assert.Equal(10, result.Values[0].Value);
Assert.Equal(107, result.Values[6].Value);
Assert.Equal(210 + 657, result.Total.Value);
Assert.Equal(BucketStatus.Available, result.Total.Status);
}
[Fact]
public void A_gap_inside_a_meters_lifetime_is_still_missing()
{
var withGap = Source(A, Monthly((2025, 1, 100))) with { InstalledAt = new DateOnly(2020, 1, 1) };
var result = Evaluate("m1 + m2", withGap, SourceB());
Assert.Equal(BucketStatus.Missing, result.Values[1].Status);
Assert.Equal([Self, A], result.Values[1].DependencyPath!);
}
[Fact]
public void A_source_that_resolves_only_months_leaves_day_buckets_unresolved()
{
var days = Days(new DateOnly(2025, 1, 1), 31);
var monthlyA = Source(A, Monthly((2025, 1, 100))) with
{
BucketStates = [.. days.Select(_ => Unresolved())],
PeriodState = BucketValue.Available(100, Provenance.Imported),
};
var dailyB = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 2, 1), 5));
var result = Evaluate("m1 + m2", days, [monthlyA, dailyB]);
Assert.All(result.Values, v =>
{
Assert.Equal(BucketStatus.Unresolved, v.Status);
Assert.Null(v.Value);
Assert.Equal(ValueIssue.CoarseResolution, v.Issue);
Assert.Equal([Self, A], v.DependencyPath!);
});
// The month as a whole is resolved: the period total is the full 100 + 31 × 5.
Assert.Equal(BucketValue.Available(255, Provenance.Derived | Provenance.Imported | Provenance.Measured), result.Total);
}
[Fact]
public void A_source_reporting_unresolved_buckets_must_also_state_its_period()
{
// Whether the range as a whole is resolved does not follow from its buckets: every day of January is unresolved
// for a monthly source, yet January resolves (the test above). Guessing either way would be wrong somewhere.
var days = Days(new DateOnly(2025, 1, 1), 31);
var monthlyA = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [.. days.Select(_ => Unresolved())] };
Assert.Throws<ArgumentException>(() => Evaluate("m1", days, [monthlyA]));
}
[Fact]
public void Without_a_period_state_only_a_pending_or_invalid_bucket_decides_the_total()
{
var partialEdges = SourceA() with
{
BucketStates = [new BucketValue(100, BucketStatus.Partial, Provenance.Imported, ValueIssue.PartialCoverage), BucketValue.Available(80, Provenance.Imported)],
};
Assert.Equal(BucketStatus.Available, Evaluate("m1 + m2", partialEdges, SourceB()).Total.Status);
var pendingFebruary = SourceA() with
{
BucketStates = [BucketValue.Available(100, Provenance.Imported), new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending)],
};
var total = Evaluate("m1 + m2", pendingFebruary, SourceB()).Total;
Assert.Equal(BucketStatus.Pending, total.Status);
Assert.Equal([Self, A], total.DependencyPath!);
}
[Fact]
public void A_source_whose_bucket_state_is_partial_keeps_the_result_partial_even_when_its_days_are_covered()
{
// A's coverage ends at 10:00 while now is 15:00: the day holds A's row, but A itself says the day is partial.
var day = new DateOnly(2026, 3, 10);
var partial = new BucketValue(3, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap, "10:00");
var a = Source(A, Daily(day, day.AddDays(1), 3)) with { BucketStates = [partial], PeriodState = partial };
var b = Source(B, Daily(day, day.AddDays(1), 1));
var result = Evaluate("m1 + m2", [Day(day)], [a, b]);
foreach (var value in new[] { result.Values[0], result.Total })
{
Assert.Equal(BucketStatus.Partial, value.Status);
Assert.Equal(4, value.Value);
Assert.Equal(ValueIssue.SampleGap, value.Issue);
Assert.Equal("10:00", value.IssueDetail);
Assert.Equal([Self, A], value.DependencyPath!);
}
Assert.Equal(BucketStatus.Partial, result.Contributions.Single(c => c.MeterId == A).Values[0].Status);
}
[Fact]
public void Bucket_states_that_do_not_line_up_with_the_buckets_are_refused()
{
var oneState = SourceA() with { BucketStates = [BucketValue.Available(100, Provenance.Imported)] };
Assert.Throws<ArgumentException>(() => Evaluate("m1 + m2", oneState, SourceB()));
}
[Fact]
public void A_nested_meter_evaluated_over_other_buckets_is_refused()
{
// Evaluated by month, the nested January value (36) would otherwise land on 1 January of a day series.
var nested = Evaluate("m1 + 5", JanFeb, [SourceA()], meterId: 10);
var days = Days(new DateOnly(2025, 1, 1), 2);
Assert.Throws<ArgumentException>(() => Evaluate("m10 + m2", days, [VirtualSource.FromEvaluation(nested), Source(B, Daily(days[0].FirstDay, days[^1].EndDay, 1))]));
}
[Fact]
public void A_source_still_being_built_makes_every_bucket_pending()
{
var pending = VirtualSource.Failed(B, BucketStatus.Pending, ValueIssue.AnalysisPending);
var result = Evaluate("m1 + m2", SourceA(), pending);
Assert.All(result.Values.Append(result.Total), v =>
{
Assert.Equal(BucketStatus.Pending, v.Status);
Assert.Equal(ValueIssue.AnalysisPending, v.Issue);
Assert.Equal([Self, B], v.DependencyPath!);
});
}
[Fact]
public void A_referenced_meter_without_any_series_is_a_missing_source()
{
var result = Evaluate("m1 + m2", SourceA());
Assert.All(result.Values, v =>
{
Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (v.Status, v.Issue));
Assert.Equal([Self, B], v.DependencyPath!);
});
}
[Fact]
public void No_coverage_at_all_is_missing_without_a_culprit()
{
var result = Evaluate("m1 + m2", Source(A, []), Source(B, []));
Assert.All(result.Values, v => Assert.Equal(BucketValue.Missing(), v));
Assert.Equal(BucketValue.Missing(), result.Total);
Assert.Null(result.FirstJointDay);
}
[Fact]
public void A_formula_without_meters_is_an_invalid_definition()
{
var result = Evaluate("5");
Assert.All(result.Values.Append(result.Total), v => Assert.Equal((BucketStatus.Invalid, ValueIssue.InvalidDefinition), (v.Status, v.Issue)));
}
[Fact]
public void Nested_virtual_meters_resolve_through_their_evaluation_and_report_the_path_to_a_missing_leaf()
{
const int sum = 10;
var inner = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], meterId: sum);
var c = Source(3, Monthly((2025, 1, 50), (2025, 2, 50)));
var outer = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(inner), c]);
Assert.Equal([200d, 150d], outer.Values.Select(v => v.Value!.Value));
Assert.Equal(350, outer.Total.Value);
Assert.Equal([250d, 200d], outer.Contributions.Single(x => x.MeterId == sum).Values.Select(v => v.Value!.Value));
var innerWithGap = Evaluate("m1 + m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 150)))], meterId: sum);
var outerWithGap = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(innerWithGap), c]);
var february = outerWithGap.Values[1];
Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (february.Status, february.Issue));
Assert.Equal([Self, sum, B], february.DependencyPath!);
}
[Fact]
public void A_nested_ratio_enters_with_its_own_bucket_values_and_its_division_by_zero_surfaces_as_invalid()
{
// Monthly data books each month on its last day, so the ratio's days are mostly 0 / 0: summing them would be
// meaningless. A non-additive source therefore enters with its bucket value (2 in January), and makes the
// outer series non-additive as well.
var ratio = Evaluate("m1 / m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 50), (2025, 2, 0)))], meterId: 10, kind: QuantityKind.Indicator);
var outer = Evaluate("m10 * 2", JanFeb, [VirtualSource.FromEvaluation(ratio)], kind: QuantityKind.Indicator);
Assert.Equal(4, outer.Values[0].Value);
Assert.Equal((BucketStatus.Invalid, ValueIssue.NonFinite), (outer.Values[1].Status, outer.Values[1].Issue));
Assert.Equal([Self, 10], outer.Values[1].DependencyPath!);
Assert.Equal(2 * 180d / 50d, outer.Total.Value!.Value, 12);
Assert.False(outer.IsAdditive);
}
[Fact]
public void A_nested_meter_on_a_loop_makes_the_outer_meter_invalid_with_the_loop_path()
{
var looped = VirtualSource.Failed(10, BucketStatus.Invalid, ValueIssue.DependencyCycle, [10, 12, 10]);
var result = Evaluate("m10 + m1", looped, SourceA());
Assert.All(result.Values, v =>
{
Assert.Equal((BucketStatus.Invalid, ValueIssue.DependencyCycle), (v.Status, v.Issue));
Assert.Equal([Self, 10, 12, 10], v.DependencyPath!);
});
}
[Fact]
public void A_sources_issue_detail_is_data_and_never_read_as_a_path()
{
// A detail that happens to be all digits (a year, a meter named "2") stays detail; the path is ids only.
var days = Days(new DateOnly(2025, 1, 1), 2);
var coarse = new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution, "2");
var a = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [coarse, coarse], PeriodState = coarse };
var result = Evaluate("m1", days, [a]);
Assert.Equal("2", result.Values[0].IssueDetail);
Assert.Equal([Self, A], result.Values[0].DependencyPath!);
}
[Fact]
public void Per_day_results_cover_exactly_the_joint_days_so_a_parent_can_use_them()
{
var jan = new DateOnly(2025, 1, 1);
var a = Source(A, Daily(jan, jan.AddDays(10), 3));
var b = Source(B, Daily(jan.AddDays(5), jan.AddDays(20), 1));
var result = Evaluate("m1 - m2", [Month(2025, 1)], [a, b]);
Assert.Equal(5, result.Days.Count);
Assert.All(result.Days.Values, d => Assert.Equal(2, d.Amount));
Assert.Equal(jan.AddDays(5), result.FirstJointDay);
Assert.Equal(jan.AddDays(9), result.LastJointDay);
Assert.Equal(10, result.Values[0].Value);
}
[Fact]
public void Per_day_results_carry_whether_every_source_was_divided_at_months()
{
var divided = Evaluate("m1 + m2", SourceA(), SourceB());
var undivided = Evaluate("m1 + m2", Source(A, Monthly(false, (2025, 1, 100), (2025, 2, 80))), SourceB());
Assert.All(divided.Days.Values, d => Assert.True(d.DividedAtMonths));
Assert.All(undivided.Days.Values, d => Assert.False(d.DividedAtMonths));
}
[Fact]
public void An_uncovered_amount_such_as_an_opening_balance_never_enters_a_sum()
{
var jan = new DateOnly(2025, 1, 1);
var days = Daily(jan, jan.AddDays(31), 1);
days[jan] = new SourceDay(5000, false, ResolutionClass.Day, Provenance.OpeningBalance);
var result = Evaluate("m1", [Month(2025, 1)], [Source(A, days)]);
Assert.Equal(BucketStatus.Partial, result.Values[0].Status);
Assert.Equal(30, result.Values[0].Value);
}
}
@@ -0,0 +1,115 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Builders for virtual-meter tests: UTC buckets and source day maps in the shapes the reader produces.</summary>
internal static class VirtualFixtures
{
public static DateTimeOffset Utc(DateOnly day) => new(day.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
public static AnalysisBucket Month(int year, int month)
{
var first = new DateOnly(year, month, 1);
var end = first.AddMonths(1);
return new AnalysisBucket(first, end, Utc(first), Utc(end), BucketSize.Month);
}
public static AnalysisBucket[] Months(int year, int firstMonth, int count) =>
[.. Enumerable.Range(0, count).Select(i => new DateOnly(year, firstMonth, 1).AddMonths(i)).Select(d => Month(d.Year, d.Month))];
public static AnalysisBucket Day(DateOnly day) => new(day, day.AddDays(1), Utc(day), Utc(day.AddDays(1)), BucketSize.Day);
public static AnalysisBucket[] Days(DateOnly first, int count) =>
[.. Enumerable.Range(0, count).Select(i => Day(first.AddDays(i)))];
/// <summary>
/// A monthly-resolution series, as an imported monthly table produces it: every day of each listed month is
/// covered by a month-long run, and the month's amount is booked on its last day. Each interval lies inside its
/// month, so the run is divided at months (A-02) unless <paramref name="dividedAtMonths"/> says otherwise (a tank
/// read on the 15th).
/// </summary>
public static Dictionary<DateOnly, SourceDay> Monthly(bool dividedAtMonths, params (int Year, int Month, double Amount)[] months)
{
var days = new Dictionary<DateOnly, SourceDay>();
foreach (var (year, month, amount) in months)
{
var first = new DateOnly(year, month, 1);
var last = first.AddMonths(1).AddDays(-1);
for (var day = first; day <= last; day = day.AddDays(1))
{
days[day] = new SourceDay(
day == last ? amount : 0d, true, ResolutionClass.Month, day == last ? Provenance.Imported : Provenance.None, dividedAtMonths);
}
}
return days;
}
/// <summary>A monthly-resolution series divided at months, the shape of an imported monthly table.</summary>
public static Dictionary<DateOnly, SourceDay> Monthly(params (int Year, int Month, double Amount)[] months) => Monthly(true, months);
/// <summary>
/// One interval longer than a month over <c>[from, to)</c>, as a quarterly reading books it: covered at
/// <see cref="ResolutionClass.Coarse"/>, the whole amount on the last day, never divided.
/// </summary>
public static Dictionary<DateOnly, SourceDay> Coarse(DateOnly from, DateOnly to, double amount)
{
var days = new Dictionary<DateOnly, SourceDay>();
for (var day = from; day < to; day = day.AddDays(1))
{
var last = day == to.AddDays(-1);
days[day] = new SourceDay(last ? amount : 0d, true, ResolutionClass.Coarse, last ? Provenance.Imported : Provenance.None);
}
return days;
}
/// <summary>A daily-resolution series with the same amount on every day of <c>[from, to)</c>.</summary>
public static Dictionary<DateOnly, SourceDay> Daily(DateOnly from, DateOnly to, double perDay, Provenance provenance = Provenance.Measured)
{
var days = new Dictionary<DateOnly, SourceDay>();
for (var day = from; day < to; day = day.AddDays(1))
{
days[day] = new SourceDay(perDay, true, ResolutionClass.Day, provenance);
}
return days;
}
public static VirtualSource Source(int meterId, Dictionary<DateOnly, SourceDay> days) => new(meterId, days);
public static CatalogMeter Physical(int id, string name, MeterMode mode, QuantityKind kind, string unit, short energyType = 1) =>
new(id, name, mode, kind, unit, energyType);
public static CatalogMeter Virtual(int id, string name, QuantityKind kind, string unit, string? expression, short energyType = 1) =>
new(id, name, MeterMode.Virtual, kind, unit, energyType, expression is null ? null : new VirtualDefinition(expression, kind, unit));
/// <summary>
/// The seeded electricity meters (ReferenceDataImporter): Haus 1, Netz 2, Auto 3, Solar 1 = 4, Solar 2 = 5 and the
/// legacy virtual Summe Solar 6, plus a water meter 7 of another energy type.
/// </summary>
public static List<CatalogMeter> SeededElectricity() =>
[
Physical(1, "Zähler Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"),
Physical(2, "Zähler Netz", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"),
Physical(3, "Zähler Auto", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"),
Physical(4, "Solar 1", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"),
Physical(5, "Solar 2", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"),
new CatalogMeter(6, "Summe Solar", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1),
Physical(7, "Wasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m³", energyType: 2),
];
/// <summary>The seeded links: Solar 1 → Summe, Solar 2 → Summe, Netz → Haus, Summe → Haus, Haus → Auto.</summary>
public static List<MeterLink> SeededLinks() =>
[
Link(4, 6),
Link(5, 6),
Link(2, 1),
Link(6, 1),
Link(1, 3),
];
public static MeterLink Link(int from, int to) => new() { FromMeterId = from, ToMeterId = to };
}
@@ -0,0 +1,353 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Validation on save and on read (D-26): syntax, references, loops through nested virtual meters with their path,
/// and meaning — like is only added to like, a product of meters is an indicator with its own unit, and the cost
/// rule fits the formula's shape.
/// </summary>
public sealed class VirtualValidatorTests
{
private static readonly MeterCatalog Catalog = new(
[
.. SeededElectricity(),
Physical(8, "Einspeisung", MeterMode.CumulativeCounter, QuantityKind.Export, "kWh"),
Physical(9, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3),
Physical(10, "Gartenwasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2),
Virtual(13, "Unkonvertiert", QuantityKind.Generation, "kWh", expression: null),
Virtual(14, "Kaputt", QuantityKind.Consumption, "kWh", expression: "m1 +"),
Physical(19, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3),
Virtual(20, "Autarkie", QuantityKind.Indicator, "%", expression: "m2 / m1"),
Virtual(21, "Eigenverbrauchsquote", QuantityKind.Indicator, "%", expression: "m4 / m1"),
]);
private static VirtualValidation Validate(string expression, QuantityKind? kind = null, string? unit = null, VirtualCostRule? costRule = null, int meterId = 100) =>
VirtualValidator.Validate(new VirtualDefinition(expression, kind, unit, costRule), meterId, Catalog);
private static VirtualProblem Single(VirtualValidation validation) => Assert.Single(validation.Problems);
[Fact]
public void Summe_solar_as_a_sum_of_two_generation_meters_is_generation_in_kWh_and_not_costed()
{
// A-15: generation is never billed (D-34), so a generation sum has no source costs to add; its default rule is none.
var validation = Validate("m4 + m5", meterId: 6);
Assert.True(validation.IsValid);
Assert.Equal(QuantityKind.Generation, validation.Kind);
Assert.Equal("kWh", validation.Unit);
Assert.Equal(VirtualCostRule.None, validation.CostRule);
}
[Fact]
public void Source_costs_over_a_nested_difference_are_refused_for_saving_and_not_costed_when_read()
{
// Review R2 (A-15): m30 + m3 is a pure sum at its own level, but m30 = m1 - m2, so the sources' metered costs
// (m1 + m2 + m3) are not the costs of its quantity (m1 - m2 + m3). The quantity stays valid; the rule does not.
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
Virtual(30, "Haus ohne Netz", QuantityKind.Consumption, "kWh", expression: "m1 - m2"),
Virtual(31, "Haus und Auto", QuantityKind.Consumption, "kWh", expression: "m1 + m3"),
]);
var declared = VirtualValidator.Validate(new VirtualDefinition("m30 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog);
Assert.True(declared.IsValid);
Assert.False(declared.IsSavable);
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, declared.CostRuleProblem!.Kind);
Assert.Equal([30], declared.CostRuleProblem.MeterIds);
Assert.Equal(VirtualCostRule.None, declared.CostRule);
var inferred = VirtualValidator.Validate(new VirtualDefinition("m30 + m3"), 40, catalog);
Assert.Equal(VirtualCostRule.None, inferred.CostRule);
Assert.Null(inferred.CostRuleProblem);
// A sum over a nested pure sum is a pure sum all the way down.
var nestedSum = VirtualValidator.Validate(new VirtualDefinition("m31 + m2", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog);
Assert.True(nestedSum.IsSavable);
Assert.Equal(VirtualCostRule.SourceCosts, nestedSum.CostRule);
Assert.Null(VirtualValidator.NestedNonSum(nestedSum.Formula!, 40, catalog));
}
[Fact]
public void Netz_einsparung_as_haus_minus_netz_is_consumption_and_may_be_priced_as_its_own_quantity()
{
var byDefault = Validate("m1 - m2");
Assert.True(byDefault.IsValid);
Assert.Equal(QuantityKind.Consumption, byDefault.Kind);
Assert.Equal(VirtualCostRule.None, byDefault.CostRule);
Assert.True(Validate("m1 - m2", costRule: VirtualCostRule.OwnQuantity).IsValid);
}
[Fact]
public void A_syntax_error_is_reported_with_its_position()
{
var problem = Single(Validate("m1 +"));
Assert.Equal(VirtualProblemKind.Syntax, problem.Kind);
Assert.Equal(new FormulaError(FormulaErrorKind.UnexpectedEnd, 4), problem.SyntaxError);
}
[Fact]
public void Unknown_meters_are_named()
{
var problem = Single(Validate("m1 + m99"));
Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind);
Assert.Equal([99], problem.MeterIds);
}
[Fact]
public void A_meter_may_not_refer_to_itself()
{
var problem = Single(Validate("m6 + m4", meterId: 6));
Assert.Equal(VirtualProblemKind.SelfReference, problem.Kind);
Assert.Equal([6], problem.MeterIds);
}
[Fact]
public void A_loop_through_nested_virtual_meters_is_reported_with_its_path()
{
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
Virtual(20, "A", QuantityKind.Consumption, "kWh", "m21 + m1"),
Virtual(21, "B", QuantityKind.Consumption, "kWh", "m22 - m2"),
Virtual(22, "C", QuantityKind.Consumption, "kWh", "m1"),
]);
// Saving C as "m20" closes the loop C → A → B → C.
var closing = VirtualValidator.Validate(new VirtualDefinition("m20"), 22, catalog);
var problem = Assert.Single(closing.Problems);
Assert.Equal(VirtualProblemKind.DependencyCycle, problem.Kind);
Assert.Equal([22, 20, 21, 22], problem.MeterIds);
// A meter that only reads the loop is caught too, with the way into it.
var looped = new MeterCatalog([.. catalog.Meters.Where(m => m.MeterId != 22), Virtual(22, "C", QuantityKind.Consumption, "kWh", "m20")]);
var reader = VirtualValidator.Validate(new VirtualDefinition("m21 + m3"), 23, looped);
Assert.Equal([23, 21, 22, 20, 21], Assert.Single(reader.Problems).MeterIds);
}
[Fact]
public void Adding_different_units_is_refused_even_for_a_declared_net_result()
{
foreach (var kind in new QuantityKind?[] { null, QuantityKind.Net })
{
var problem = Single(Validate("m1 + m7", kind));
Assert.Equal(VirtualProblemKind.UnitMismatch, problem.Kind);
Assert.Equal([1, 7], problem.MeterIds);
Assert.Equal(["kWh", "m³"], problem.Values);
}
}
[Fact]
public void Superscript_and_plain_cubic_metres_are_the_same_unit_and_the_result_is_in_canonical_spelling()
{
var water = Validate("m10 + m7");
Assert.True(water.IsValid);
Assert.Equal("m³", water.Unit);
}
[Fact]
public void Units_are_compared_through_the_shared_unit_table_ignoring_case()
{
var typed = Validate("m1 - m2", unit: "kwh");
Assert.True(typed.IsValid);
Assert.Equal("kWh", typed.Unit);
Assert.Equal("kWh", typed.EffectiveDefinition!.ResultUnit);
}
[Theory]
[InlineData(QuantityKind.Runtime)]
[InlineData(QuantityKind.Export)]
[InlineData(QuantityKind.Cost)]
public void A_result_kind_is_consumption_generation_net_or_indicator_and_nothing_else(QuantityKind kind)
{
var problem = Single(Validate("m9 + m19", kind));
Assert.Equal(VirtualProblemKind.ResultKindUnsupported, problem.Kind);
}
[Fact]
public void A_sum_of_runtime_meters_needs_a_declared_result_kind()
{
// Two burners' hours are not consumption or generation; without a declaration the reader would take them for
// consumption (D-20's fallback) and a runtime result is not one a virtual meter can have.
var undeclared = Single(Validate("m9 + m19"));
Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind);
Assert.Equal(["runtime"], undeclared.Values);
var net = Validate("m9 + m19", QuantityKind.Net);
Assert.True(net.IsValid);
Assert.Equal("h", net.Unit);
}
[Fact]
public void Indicators_are_never_added_up_into_anything_but_an_indicator()
{
var asNet = Single(Validate("m20 + m21", QuantityKind.Net));
Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, asNet.Kind);
Assert.Equal([20, 21], asNet.MeterIds);
Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, Single(Validate("0.5 * m20", QuantityKind.Net)).Kind);
Assert.Equal(VirtualProblemKind.ResultKindRequired, Single(Validate("m20 + m21")).Kind);
Assert.Equal(VirtualProblemKind.ResultKindMismatch, Single(Validate("m20", QuantityKind.Consumption)).Kind);
var indicator = Validate("m20 + m21", QuantityKind.Indicator);
Assert.True(indicator.IsValid);
Assert.Equal(VirtualCostRule.None, indicator.CostRule);
}
[Fact]
public void A_formula_over_an_indicator_is_never_costed()
{
Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, Single(Validate("m20 + m21", QuantityKind.Indicator, costRule: VirtualCostRule.SourceCosts)).Kind);
var net = Validate("m20 + m21", QuantityKind.Net, costRule: VirtualCostRule.SourceCosts);
Assert.Equal(
[VirtualProblemKind.IndicatorSourceNeedsIndicator, VirtualProblemKind.CostRuleNotForIndicator],
net.Problems.Select(p => p.Kind));
// Undeclared, the cost rule over an indicator is never the pure-sum default.
Assert.Equal(VirtualCostRule.None, Validate("m20 + m21", QuantityKind.Net).CostRule);
}
[Fact]
public void The_effective_definition_writes_out_the_inferred_kind_unit_and_cost_rule()
{
var summeSolar = Validate("m4 + m5", meterId: 6);
Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), summeSolar.EffectiveDefinition);
Assert.Equal(new VirtualDefinition("m7 + m10", QuantityKind.Consumption, "m³", VirtualCostRule.SourceCosts), Validate("m7 + m10").EffectiveDefinition);
Assert.Null(Validate("m1 - m4").EffectiveDefinition);
}
[Fact]
public void Mixing_kinds_needs_a_declared_net_result()
{
var undeclared = Single(Validate("m1 - m4"));
Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind);
Assert.Equal([1, 4], undeclared.MeterIds);
Assert.Equal(["consumption", "generation"], undeclared.Values);
var asConsumption = Single(Validate("m1 - m4", QuantityKind.Consumption));
Assert.Equal(VirtualProblemKind.KindMismatch, asConsumption.Kind);
Assert.Equal([1, 4], asConsumption.MeterIds);
var net = Validate("m2 - m8", QuantityKind.Net);
Assert.True(net.IsValid);
Assert.Equal(QuantityKind.Net, net.Kind);
Assert.Equal("kWh", net.Unit);
}
[Fact]
public void A_product_or_quotient_of_meters_must_be_a_declared_indicator_with_its_own_unit()
{
Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m1 * m2")).Kind);
Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m7 / m1", QuantityKind.Consumption)).Kind);
Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("1 / m1")).Kind);
Assert.Equal(VirtualProblemKind.IndicatorNeedsUnit, Single(Validate("m7 / m1", QuantityKind.Indicator)).Kind);
var ratio = Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh");
Assert.True(ratio.IsValid);
Assert.Equal("m³/kWh", ratio.Unit);
Assert.Equal(VirtualCostRule.None, ratio.CostRule);
}
[Fact]
public void Indicators_are_never_costed()
{
var problem = Single(Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh", VirtualCostRule.OwnQuantity));
Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, problem.Kind);
}
[Fact]
public void Scaling_by_a_constant_keeps_the_quantity_and_its_unit()
{
var half = Validate("0.5 * m1 + m2 / 2", costRule: VirtualCostRule.OwnQuantity);
Assert.True(half.IsValid);
Assert.Equal(QuantityKind.Consumption, half.Kind);
Assert.Equal("kWh", half.Unit);
}
[Fact]
public void Source_costs_need_a_pure_sum_and_own_quantity_a_linear_formula()
{
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("m1 - m2", costRule: VirtualCostRule.SourceCosts)).Kind);
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("0.5 * m1", costRule: VirtualCostRule.SourceCosts)).Kind);
Assert.Equal(VirtualProblemKind.CostRuleNeedsLinear, Single(Validate("m1 + 5", costRule: VirtualCostRule.OwnQuantity)).Kind);
Assert.True(Validate("m1 + 5").IsValid); // allowed, but non-additive and not priceable
}
[Fact]
public void A_declared_kind_or_unit_that_contradicts_the_sources_is_refused()
{
var kind = Single(Validate("m4 + m5", QuantityKind.Consumption));
Assert.Equal(VirtualProblemKind.ResultKindMismatch, kind.Kind);
Assert.Equal(["consumption", "generation"], kind.Values);
var unit = Single(Validate("m1 - m2", unit: "MWh"));
Assert.Equal(VirtualProblemKind.ResultUnitMismatch, unit.Kind);
Assert.Equal(["MWh", "kWh"], unit.Values);
Assert.Equal(VirtualProblemKind.ResultKindUnsupported, Single(Validate("m1", QuantityKind.Cost)).Kind);
}
[Fact]
public void A_formula_without_meters_is_not_a_meter()
{
Assert.Equal(VirtualProblemKind.NoReferences, Single(Validate("5")).Kind);
}
[Fact]
public void Nested_virtual_sources_must_themselves_be_configured_and_parse()
{
Assert.Equal(VirtualProblemKind.SourceNotConfigured, Single(Validate("m13 + m4")).Kind);
Assert.Equal(VirtualProblemKind.SourceInvalid, Single(Validate("m14 + m1")).Kind);
}
[Fact]
public void A_nested_virtual_source_counts_with_its_result_kind_and_unit()
{
var catalog = new MeterCatalog([.. SeededElectricity(), Virtual(20, "Summe Solar", QuantityKind.Generation, "kWh", "m4 + m5")]);
var validation = VirtualValidator.Validate(new VirtualDefinition("m20 - m4"), 21, catalog);
Assert.True(validation.IsValid);
Assert.Equal(QuantityKind.Generation, validation.Kind);
}
[Theory]
[InlineData(new[] { QuantityKind.Generation, QuantityKind.Generation }, QuantityKind.Generation)]
[InlineData(new[] { QuantityKind.Consumption }, QuantityKind.Consumption)]
[InlineData(new[] { QuantityKind.Runtime, QuantityKind.Runtime }, null)]
[InlineData(new[] { QuantityKind.Export }, null)]
[InlineData(new[] { QuantityKind.Net }, null)]
[InlineData(new[] { QuantityKind.Consumption, QuantityKind.Generation }, null)]
[InlineData(new[] { QuantityKind.Indicator }, null)]
[InlineData(new QuantityKind[0], null)]
public void The_default_kind_is_consumption_or_generation_when_every_source_shares_it_and_none_otherwise(QuantityKind[] kinds, QuantityKind? expected)
{
Assert.Equal(expected, VirtualValidator.DefaultKind(kinds));
}
[Theory]
[InlineData("m4 + m5", null, VirtualCostRule.SourceCosts)]
[InlineData("m4 + m5", QuantityKind.Indicator, VirtualCostRule.None)]
[InlineData("m1 - m2", null, VirtualCostRule.None)]
[InlineData("m1 / m2", QuantityKind.Indicator, VirtualCostRule.None)]
public void The_default_cost_rule_is_source_costs_for_pure_sums_and_none_otherwise(string expression, QuantityKind? kind, VirtualCostRule expected)
{
Assert.Equal(expected, VirtualValidator.DefaultCostRule(Formula.Parse(expression), kind));
}
}
@@ -1,36 +0,0 @@
using MeterVault.Core.Normalization.Expressions;
namespace MeterVault.Core.Tests;
public sealed class ExpressionEvaluatorTests
{
private static readonly Dictionary<string, double> Vars = new()
{
["m1"] = 411,
["m2"] = 416,
};
[Theory]
[InlineData("1 + 2 * 3", 7)]
[InlineData("(1 + 2) * 3", 9)]
[InlineData("-5", -5)]
[InlineData("10 / 4", 2.5)]
[InlineData("2 - 3 - 4", -5)] // left-associative
[InlineData("m1 - m2", -5)]
[InlineData("m1 + m2", 827)]
[InlineData("unknown + 1", 1)] // unknown identifiers resolve to 0
public void Evaluates_arithmetic(string expression, double expected)
{
var result = ExpressionEvaluator.Compile(expression).Evaluate(Vars);
Assert.Equal(expected, result, 6);
}
[Theory]
[InlineData("1 +")]
[InlineData("(1 + 2")]
[InlineData("1 2")]
[InlineData("")]
public void Rejects_malformed_expressions(string expression) =>
Assert.ThrowsAny<Exception>(() => ExpressionEvaluator.Compile(expression));
}
+27 -52
View File
@@ -1,75 +1,50 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.TestData;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
namespace MeterVault.Core.Tests;
/// <summary>
/// The electricity derived columns are data-driven virtual expressions, not hardcoded formulas
/// (SDD §2.2, §7.4): Netz Einsparung = Haus − Netz, Anlage Eigenverbrauch = Erzeugung − Einsparung.
/// (SDD §2.2, §7.4): Netz Einsparung = Haus − Netz, Anlage Eigenverbrauch = Erzeugung − Einsparung. Ported from
/// the removed VirtualNormalizer to the evaluator that runs on read (D-29), over month buckets.
/// </summary>
public sealed class VirtualMeterTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Consumption Cons(int meterId, DateTimeOffset time, double amount) => new()
{
MeterId = meterId,
Time = time,
Amount = amount,
Kind = ConsumptionKind.Consumption,
Quality = ReadingQuality.Imported,
};
private const int Haus = 1;
private const int Netz = 2;
private const int Solar = 50;
private const int NetzEinsparung = 100;
private const int Eigenverbrauch = 101;
[Fact]
public void Netz_einsparung_is_haus_minus_netz()
{
var oct = Month(2022, 10);
var nov = Month(2022, 11);
var ctx = new NormalizationContext
{
Meter = new MeterConfig
{
MeterId = 100,
Mode = MeterMode.Virtual,
Unit = "kWh",
Virtual = new VirtualSpec { Expression = "m1 - m2", ReferencedMeterIds = [1, 2] },
},
ReferencedSeries = new Dictionary<int, IReadOnlyList<Consumption>>
{
[1] = [Cons(1, oct, 411), Cons(1, nov, 742)], // Haus Verbrauch
[2] = [Cons(2, oct, 416), Cons(2, nov, 832)], // Netz Verbrauch
},
};
var haus = Source(Haus, Monthly((2022, 10, 411), (2022, 11, 742))); // Haus Verbrauch
var netz = Source(Netz, Monthly((2022, 10, 416), (2022, 11, 832))); // Netz Verbrauch
var result = _engine.Normalize(ctx);
var result = VirtualEvaluator.Evaluate(
NetzEinsparung, Formula.Parse("m1 - m2"), QuantityKind.Consumption, Months(2022, 10, 2), [haus, netz]);
Assert.Equal([-5d, -90d], result.Select(c => c.Amount));
Assert.Equal([-5d, -90d], result.Values.Select(v => v.Value!.Value));
}
[Fact]
public void Eigenverbrauch_is_erzeugung_minus_einsparung()
{
var oct = Month(2022, 10);
var ctx = new NormalizationContext
{
Meter = new MeterConfig
{
MeterId = 101,
Mode = MeterMode.Virtual,
Unit = "kWh",
Virtual = new VirtualSpec { Expression = "m50 - m100", ReferencedMeterIds = [50, 100] },
},
ReferencedSeries = new Dictionary<int, IReadOnlyList<Consumption>>
{
[50] = [Cons(50, oct, 76)], // Solar Erzeugung
[100] = [Cons(100, oct, -5)], // Netz Einsparung (from the previous test)
},
};
var october = Months(2022, 10, 1);
var einsparung = VirtualEvaluator.Evaluate(
NetzEinsparung,
Formula.Parse("m1 - m2"),
QuantityKind.Consumption,
october,
[Source(Haus, Monthly((2022, 10, 411))), Source(Netz, Monthly((2022, 10, 416)))]);
var solar = Source(Solar, Monthly((2022, 10, 76))); // Solar Erzeugung
var result = _engine.Normalize(ctx);
var result = VirtualEvaluator.Evaluate(
Eigenverbrauch, Formula.Parse("m50 - m100"), QuantityKind.Net, october, [solar, VirtualSource.FromEvaluation(einsparung)]);
// 76 − (−5) = 81 (Anlage Eigenverbrauch Okt 2022).
Assert.Equal([81d], result.Select(c => c.Amount));
// 76 − (−5) = 81 (Anlage Eigenverbrauch Okt 2022), through the nested Netz Einsparung meter.
Assert.Equal([81d], result.Values.Select(v => v.Value!.Value));
}
}
@@ -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 A−B = −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}.";
}
}
}

Some files were not shown because too many files have changed in this diff Show More