Analysis: read a rarely-read meter as coarse, not absent; newest rows first
ci / build-test (push) Successful in 2m41s

Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
This commit is contained in:
Florian Schmidt
2026-09-20 12:44:32 +02:00
parent 5a6f34a467
commit a08e9f781f
38 changed files with 1301 additions and 184 deletions
@@ -336,6 +336,7 @@ public sealed class BucketPlannerTests
// 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);
@@ -335,6 +335,93 @@ public sealed class CoverageEvaluatorTests
Assert.Equal(0d, coverage.CoveredFraction);
}
// ---- Waiting for a measurement coarser than the bucket (A-41) ------------------------------------
[Fact]
public void Buckets_after_a_dipstick_wait_for_the_next_one_instead_of_reading_as_no_data()
{
// A heating-oil tank dipped on 5 October 2024 and 20 September 2025: one interval, coarser than a month.
// "Last 12 months" opens eleven days after the last dipstick, so nothing covers it — but the tank is
// neither silent nor broken, it is read twice a year. Every bucket says "only coarser data", with that
// resolution, and none of them claims a number.
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
var october = Evaluate(Month(2025, 10), dipsticks);
var august = Evaluate(Month(2026, 8), dipsticks);
Assert.Equal(BucketStatus.Unresolved, october.Status);
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
Assert.Equal(TimeSpan.Zero, october.Covered);
Assert.Null(october.ToValue(0, Provenance.None).Value);
Assert.Equal(BucketStatus.Unresolved, august.Status);
}
[Fact]
public void A_bucket_before_a_meters_first_reading_is_missing_not_waiting()
{
// Nothing precedes it: the tank had not been dipped once, so there is no measurement on its way.
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2024, 3), dipsticks).Status);
}
[Fact]
public void A_meter_that_books_its_own_buckets_still_reads_as_no_data_where_it_has_none()
{
// The rule is only for data too coarse for the bucket: an hourly source that went silent in March, and a
// monthly sheet that ends in March, both book their buckets as they go, so a bucket they do not cover
// really has no data.
var hourly = Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Hour);
var sheet = MonthLabels(2026, 1, 2026, 4);
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 4), hourly).Status);
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2026, 4, 2), hourly).Status);
Assert.Equal(BucketStatus.Missing, Evaluate(Month(2026, 5), sheet).Status);
Assert.Equal(BucketStatus.Missing, Evaluate(Year(2027), sheet).Status);
}
[Fact]
public void A_monthly_sheet_waits_a_month_for_its_next_row_and_then_has_no_data()
{
// Monthly rows cannot fill a day bucket at all, so the days right after the sheet ends are unresolved —
// the April row will book them. Two years on, the sheet is not late but over (A-04, LimitOf).
var sheet = MonthLabels(2026, 1, 2026, 4);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Day(2026, 4, 2), sheet).Status);
Assert.Equal(ResolutionClass.Month, Evaluate(Day(2026, 4, 2), sheet).Resolution);
Assert.Equal(BucketStatus.Missing, Evaluate(Day(2028, 4, 2), sheet).Status);
}
[Fact]
public void A_known_hole_keeps_its_own_reason_rather_than_waiting()
{
// A gap run overlapping the bucket is a hole someone has to explain, not a measurement on its way.
CoverageRun[] runs =
[
Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse),
Gap(At(2025, 9, 20, 10), At(2026, 2, 1), CoverageGapReason.UnexplainedDecrease),
];
var coverage = Evaluate(Month(2025, 11), runs);
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
}
[Fact]
public void A_series_reads_the_waiting_buckets_exactly_as_one_by_one()
{
var dipsticks = Single(At(2024, 10, 5, 10), At(2025, 9, 20, 10), ResolutionClass.Coarse);
List<AnalysisBucket> months = [.. Enumerable.Range(0, 12).Select(i => Month(2025 + ((9 + i) / 12), (((9 + i) % 12) + 1)))];
var series = CoverageEvaluator.EvaluateSeries(months, [dipsticks], Berlin, null);
Assert.Equal(months.Select(m => Evaluate(m, dipsticks).Status), series.Select(c => c.Status));
Assert.All(series, c => Assert.Equal(BucketStatus.Unresolved, c.Status));
Assert.All(series, c => Assert.Equal(ResolutionClass.Coarse, c.Resolution));
}
[Fact]
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
{
@@ -92,6 +92,70 @@ public sealed class ResolutionClassifierTests
Assert.Throws<ArgumentOutOfRangeException>(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto));
}
// ---- What a plotted run asks the bucket planner for (A-41) ---------------------------------------
private static CoverageRun Interval(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) =>
new(from, to, resolution, divided, CoverageGapReason.None, from);
[Fact]
public void Quarterly_intervals_inside_one_year_ask_for_years()
{
// Read on the quarter: every interval lies inside 2025, so year buckets hold each one whole.
CoverageRun[] quarters =
[
Interval(InBerlin(2025, 1, 1), InBerlin(2025, 4, 1), ResolutionClass.Coarse),
Interval(InBerlin(2025, 4, 1), InBerlin(2025, 7, 1), ResolutionClass.Coarse),
Interval(InBerlin(2025, 10, 1), InBerlin(2026, 1, 1), ResolutionClass.Coarse),
];
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution(quarters, Berlin));
Assert.Equal(BucketSize.Year, BucketPlanner.MinimumSizeFor(ResolutionClassifier.PlanningResolution(quarters, Berlin)!.Value));
}
[Fact]
public void One_interval_across_a_new_year_keeps_the_whole_chart_at_months()
{
// A dipstick read every autumn straddles the New Year as surely as every month start: year buckets leave it
// exactly as unresolved as month buckets do, so it must not coarsen the chart to years (A-41). A chart has
// one bucket size, so one such interval settles it for all of them.
var dipsticks = Interval(InBerlin(2025, 9, 20, 10), InBerlin(2026, 8, 1, 10), ResolutionClass.Coarse);
var quarter = Interval(InBerlin(2026, 1, 5), InBerlin(2026, 5, 1), ResolutionClass.Coarse);
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([dipsticks], Berlin));
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.PlanningResolution([quarter], Berlin));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([quarter, dipsticks], Berlin));
}
[Fact]
public void A_run_divided_at_the_month_edges_asks_for_months_whatever_its_length()
{
// A-03: a 46-day interval divided at 1 September is booked inside each month it touches.
var divided = Interval(InBerlin(2026, 8, 20, 9), InBerlin(2026, 10, 5, 9), ResolutionClass.Coarse, divided: true);
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.PlanningResolution([divided], Berlin));
}
[Fact]
public void Gap_runs_and_an_empty_range_ask_for_nothing()
{
var hole = new CoverageRun(InBerlin(2025, 1, 1), InBerlin(2026, 6, 1), ResolutionClass.Hour, false, CoverageGapReason.SampleGap);
Assert.Null(ResolutionClassifier.PlanningResolution([], Berlin));
Assert.Null(ResolutionClassifier.PlanningResolution([hole], Berlin));
}
[Theory]
[InlineData(ResolutionClass.Hour)]
[InlineData(ResolutionClass.Day)]
[InlineData(ResolutionClass.Week)]
[InlineData(ResolutionClass.Month)]
public void A_run_a_bucket_size_can_place_asks_for_its_own_class(ResolutionClass resolution)
{
var run = Interval(InBerlin(2025, 12, 20), InBerlin(2026, 1, 3), resolution);
Assert.Equal(resolution, ResolutionClassifier.PlanningResolution([run], Berlin));
}
[Fact]
public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value()
{
@@ -117,6 +117,12 @@ public sealed class AnalysisComponentRenderTests
Assert.Contains("<th scope=\"row\" class=\"mv-row-label\">Feb</th>", html, StringComparison.Ordinal);
Assert.Contains(">0 kWh", html, StringComparison.Ordinal);
// A-42: the total above the rows, then March, February, January.
Assert.Equal(
["Total", "Mar", "Feb", "Jan"],
System.Text.RegularExpressions.Regex.Matches(html, "<th scope=\"row\" class=\"mv-row-label\">([^<]+)</th>")
.Select(m => m.Groups[1].Value));
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);
@@ -286,6 +292,55 @@ public sealed class AnalysisComponentRenderTests
Assert.Contains("* Partial, estimated or not fully priced", some, StringComparison.Ordinal);
}
[Fact]
public async Task The_comparison_line_names_the_figure_it_is_about_when_a_page_shows_several()
{
// A-41: on one energy type page the tank's use shares no covered day with the year before while the burner
// beside it compares month by month. Unqualified, the line contradicts the card and the table under it.
var period = Range(D(2025, 10, 1), D(2026, 9, 30));
var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear), Now);
var alone = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
});
Assert.Contains("Not comparable: the two periods share no covered days", alone, StringComparison.Ordinal);
var named = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
[nameof(ComparisonSummary.Subject)] = "Total use",
});
Assert.Contains("Total use: not comparable — the two periods share no covered days", named, StringComparison.Ordinal);
Assert.DoesNotContain("Not comparable: the two", named, StringComparison.Ordinal);
var matched = await RenderAsync<ComparisonSummary>("de", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = Matched(D(2025, 10, 1), D(2026, 5, 1)),
[nameof(ComparisonSummary.Subject)] = "Laufzeit",
});
Assert.Contains("Laufzeit: Veränderung über den gemeinsam abgedeckten Zeitraum ", matched, StringComparison.Ordinal);
}
/// <summary>One matched stretch of the current period and the same stretch a year earlier.</summary>
private static MatchedCoverageResult Matched(DateOnly first, DateOnly last)
{
MatchedRange RangeOf(int years) => new(
new DateTimeOffset(first.AddYears(years).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
new DateTimeOffset(last.AddYears(years).AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
first.AddYears(years),
last.AddYears(years));
var piece = new MatchedPiece(RangeOf(0), RangeOf(-1));
return new MatchedCoverageResult(piece.Current, piece.Comparison, [piece]);
}
/// <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));
@@ -23,19 +23,46 @@ public sealed class AnalysisTableModelTests
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);
// A-42: the total first, then the buckets newest first.
Assert.Equal(["Total", "Mar", "Feb", "Jan"], table.Rows.Select(r => r.Label));
Assert.Equal(buckets[1], table.Rows[2].Bucket);
Assert.Null(table.Rows[0].Bucket);
Assert.True(table.Rows[0].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));
Assert.Equal(["120 kWh", "0 kWh", "", "120 kWh"], table.Rows.Select(r => r.Cells[0].Text));
Assert.True(table.Rows[2].Cells[0].IsUnknown);
Assert.False(table.Rows[1].Cells[0].IsUnknown);
Assert.Equal("Complete · Measured", table.Rows[3].Cells[1].Text);
Assert.Equal("No data", table.Rows[2].Cells[1].Text);
Assert.Equal("No data covers this period", table.Rows[2].Cells[1].Secondary);
Assert.Equal("Partial · Measured", table.Rows[0].Cells[1].Text);
Assert.Equal([true, false, true, false], table.Rows.Select(r => r.IsQualified));
});
[Fact]
public void Every_dated_table_reads_newest_first_with_the_total_above_the_rows() => In("en", () =>
{
// A-42: a table is read from the top, so the latest period is the first row; the plan itself stays
// chronological, and each row keeps its own bucket for the drill-down.
var buckets = Buckets(D(2025, 11, 1), D(2026, 2, 28));
var series = AnalysisTableSeries.ForSeries(
Series(1, "Öltank", [Available(10), Available(20), Available(30), Available(40)], total: Available(100)));
var table = AnalysisTableModel.Build(buckets, [series]);
Assert.Equal(["Total", "Feb 2026", "Jan 2026", "Dec 2025", "Nov 2025"], table.Rows.Select(r => r.Label));
Assert.True(table.Rows[0].IsTotal);
Assert.All(table.Rows.Skip(1), r => Assert.False(r.IsTotal));
Assert.Equal(["100 kWh", "40 kWh", "30 kWh", "20 kWh", "10 kWh"], table.Rows.Select(r => r.Cells[0].Text));
// The drill-down of a row is its own bucket, not the one at that index in the plan.
Assert.Equal([buckets[3], buckets[2], buckets[1], buckets[0]], table.Rows.Skip(1).Select(r => r.Bucket));
Assert.Equal(D(2025, 11, 1), buckets[0].FirstDay);
// Without a total row the newest bucket is simply the first row.
var plain = AnalysisTableModel.Build(buckets, [series], includeTotal: false);
Assert.Equal(["Feb 2026", "Jan 2026", "Dec 2025", "Nov 2025"], plain.Rows.Select(r => r.Label));
Assert.Empty(AnalysisTableModel.Build([], [series]).Rows);
});
[Fact]
@@ -58,18 +85,19 @@ public sealed class AnalysisTableModelTests
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));
// Newest first (A-42), so the paired bucket of each row still names the month it is compared with.
Assert.Equal(["190 kWh", "—", "90 kWh", "100 kWh"], comparison.Select(c => c.Text));
Assert.Equal([null, "Mar 2025", "Feb 2025", "Jan 2025"], 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("+20 kWh (+20.0 %)", change[3].Text);
Assert.Equal("mv-change-bad", change[3].CssClass);
Assert.Equal("—", change[2].Text);
Assert.True(change[2].IsUnknown);
Assert.Equal("—", change[1].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);
Assert.Equal("+10 kWh (+5.3 %)", change[0].Text);
});
[Fact]
@@ -82,8 +110,9 @@ public sealed class AnalysisTableModelTests
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);
Assert.True(table.Rows[0].IsTotal);
Assert.Equal("mv-change-good", table.Rows[1].Cells[3].CssClass);
Assert.Equal("mv-change-neutral", table.Rows[1].Cells[7].CssClass);
// With two series, every column but the value names its series.
Assert.Equal("Solar", table.Columns[1].SubHeader);
@@ -101,10 +130,10 @@ public sealed class AnalysisTableModelTests
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);
Assert.Equal(["50.00 €", "25.00 €", "25.00 €", ""], table.Rows.Select(r => r.Cells[2].Text));
Assert.Equal("Unavailable (tariff gap)", table.Rows[3].Cells[3].Text);
Assert.Equal("Priced", table.Rows[2].Cells[3].Text);
Assert.Equal("Partly priced", table.Rows[0].Cells[3].Text);
});
[Fact]
@@ -118,10 +147,10 @@ public sealed class AnalysisTableModelTests
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);
Assert.Equal(["50.00 €", "25.00 €", "25.00 €"], table.Rows.Select(r => r.Cells[0].Text));
Assert.Equal("+5.00 € (+25.0 %)", table.Rows[1].Cells[3].Text);
Assert.Equal("mv-change-bad", table.Rows[1].Cells[3].CssClass);
Assert.Equal("+10.00 € (+25.0 %)", table.Rows[0].Cells[3].Text);
});
[Theory]
@@ -38,15 +38,16 @@ internal static class AnalysisUiTestData
public static BucketValue Missing() => BucketValue.Missing();
/// <summary>A physical meter's series in kWh with its total and, optionally, a comparison.</summary>
/// <summary>A physical meter's series in kWh (or <paramref name="unit"/>) 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)
SeriesComparison? comparison = null,
string unit = "kWh") =>
new(SeriesKey.ForMeter(meterId, 1, unit), name, SeriesBasis.Physical, kind, unit, values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
{
Comparison = comparison,
};
@@ -0,0 +1,240 @@
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.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Options;
using MeterVault.Integration.Tests.Costing;
using MeterVault.App.Localization;
using MeterVault.App.Theme;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// A household whose heating oil is read a few times a year — a tank dipped with a stick and a burner whose hours are
/// noted now and then — read exactly as the energy type page reads it (A-41, reported from a real instance).
/// </summary>
/// <remarks>
/// <para>
/// Such a meter measures more coarsely than any bucket a chart can draw, and its last reading is often months old.
/// Three things went wrong before this fixture existed:
/// </para>
/// <list type="bullet">
/// <item><description>
/// A period that opened after the last dipstick had no coverage at all, so the type's use read "No data" beside a
/// coverage panel listing years of it, while the burner next to it read "Only coarser data" (D-14).
/// </description></item>
/// <item><description>
/// Automatic buckets answered "last 12 months" with one bar per year, because data coarser than a month dragged the
/// whole chart to years — which left it exactly as unresolved (D-05, A-06).
/// </description></item>
/// <item><description>
/// The type's comparison line spoke of the use measure alone, so it claimed the periods shared no covered days while
/// the table below compared the burner over the days both cover (D-07).
/// </description></item>
/// </list>
/// <para>Everything runs on the sandbox's frozen clock of 19 September 2026, 14:37 Berlin.</para>
/// </remarks>
[Collection("Timescale")]
public sealed class CoarseMeterAnalysisTests(TimescaleFixture fx)
{
/// <summary>The tank's dipsticks: once a year, in litres, with a delivery between two of them.</summary>
private static readonly (DateOnly Day, double Litres)[] Dipsticks =
[
(D(2022, 10, 14), 5200),
(D(2023, 9, 2), 2100),
(D(2024, 10, 5), 2600),
(D(2025, 9, 20), 3100),
];
/// <summary>
/// The burner's hour counter, read every few months — around the same days each year, so its intervals do share
/// covered days with the year before (which the tank's, dipped once every autumn, never do).
/// </summary>
private static readonly (DateOnly Day, double Hours)[] BurnerHours =
[
(D(2022, 10, 14), 1000),
(D(2023, 9, 2), 2100),
(D(2024, 1, 5), 2500),
(D(2024, 5, 1), 2900),
(D(2024, 8, 1), 2980),
(D(2024, 10, 5), 3300),
(D(2025, 1, 5), 3860),
(D(2025, 5, 1), 4286),
(D(2025, 8, 1), 4370),
(D(2025, 10, 5), 4700),
(D(2026, 1, 5), 5240),
(D(2026, 5, 1), 5629),
(D(2026, 8, 1), 5700),
];
[Fact]
public async Task A_tank_dipped_once_a_year_says_its_data_is_coarser_instead_of_claiming_there_is_none()
{
await using var box = new CostSandbox(fx);
var (type, tank, burner) = await HeatingOilAsync(box);
var page = await LoadAsync(box, type, "/energy/1?period=12m");
var quantities = page.Quantities!;
var use = Assert.Single(quantities.Measures, m => m.Key.Measure == TotalsMeasure.Use);
var runtime = Assert.Single(quantities.Measures, m => m.Key.Measure == TotalsMeasure.Runtime);
// The tank was last dipped eleven days before the period opened, so nothing covers it — but it is read once a
// year, not silent. It says so, in the same words as the burner beside it, and with the same resolution.
Assert.Equal([tank], use.MemberIds);
Assert.Equal(BucketStatus.Unresolved, use.Total.Status);
Assert.Equal(ValueIssue.CoarseResolution, use.Total.Issue);
Assert.Equal(ResolutionClass.Coarse, use.Resolution);
Assert.Null(use.Total.Value);
Assert.Equal(BucketStatus.Unresolved, runtime.Total.Status);
Assert.Equal(ResolutionClass.Coarse, runtime.Resolution);
Assert.All(use.Values, v => Assert.Equal(BucketStatus.Unresolved, v.Status));
// The card, the coverage panel and the empty chart say one thing, and it is not "no data".
AnalysisUiTestData.In("en", () =>
{
var status = FigureText.Of(use.Total);
Assert.Equal("Only coarser data", status.Status);
Assert.False(status.IsKnown);
Assert.Equal(ChartEmptyReason.Unresolved, ChartPlanOf(quantities, use).EmptyReason);
Assert.Equal("Coarser than monthly", use.Resolution!.Value.Display());
});
// Its own coverage still reaches back years, which is what made "no data" read as a contradiction.
Assert.Equal((D(2022, 10, 14), D(2025, 9, 19)), (use.Availability!.FirstDay, use.Availability.LastDay));
Assert.Equal(D(2026, 7, 31), quantities.Availability.Quantity!.LastDay);
Assert.DoesNotContain(quantities.Measures, m => m.Total.Status == BucketStatus.Missing);
Assert.Equal([tank, burner], quantities.Series.Select(s => s.MeterId!.Value).Order());
}
[Fact]
public async Task Automatic_buckets_answer_twelve_months_with_twelve_months()
{
await using var box = new CostSandbox(fx);
var (type, _, _) = await HeatingOilAsync(box);
var page = await LoadAsync(box, type, "/energy/1?period=12m");
// A-41: data coarser than a month must not coarsen the chart to a bar per year; a year holds a dipstick's
// interval no better than a month does, and one bar is not a trend.
Assert.Equal(BucketSize.Auto, page.Quantities!.Plan.Requested);
Assert.Equal(BucketSize.Month, page.Quantities.Plan.Size);
Assert.Equal(12, page.Quantities.Plan.Buckets.Count);
Assert.Equal(D(2025, 10, 1), page.Quantities.Plan.Buckets[0].FirstDay);
Assert.Equal(page.Quantities.Plan.Buckets, page.Cost!.Plan.Buckets);
}
[Fact]
public async Task A_meter_read_on_the_same_days_every_year_compares_and_the_type_line_says_which_figure_it_means()
{
await using var box = new CostSandbox(fx);
var (type, tank, burner) = await HeatingOilAsync(box);
var page = await LoadAsync(box, type, "/energy/1?period=12m");
var use = Assert.Single(page.Quantities!.Measures, m => m.Key.Measure == TotalsMeasure.Use);
var burnerSeries = Assert.Single(page.Quantities.Series, s => s.MeterId == burner);
// The burner shares covered days with the year before; the tank, dipped every autumn, shares none. One page,
// two true statements — so the comparison line names the figure it is about, and the largest-changes table
// below states its own basis (D-07).
Assert.True(page.Quantities.Comparison!.Resolution.IsApplicable);
Assert.True(burnerSeries.Comparison!.Matched.IsComparable);
Assert.False(use.Comparison!.Matched.IsComparable);
Assert.Equal([burner], MeterChanges.Largest(page.Quantities.Series, 6).Select(c => c.Series.MeterId!.Value));
Assert.DoesNotContain(MeterChanges.Largest(page.Quantities.Series, 6), c => c.Series.MeterId == tank);
}
[Fact]
public async Task The_overview_the_analysis_page_and_the_tank_view_read_the_same_coarse_data()
{
await using var box = new CostSandbox(fx);
var (type, tank, _) = await HeatingOilAsync(box);
await using (var db = fx.CreateContext())
{
db.Tanks.Add(new Tank { MeterId = tank, Capacity = 7000, Unit = "L" });
await db.SaveChangesAsync();
}
var period = Preset(PeriodPreset.Last12Months);
var reader = Reader();
var clock = new FixedTimeProvider(CostSandbox.Now);
// The Analysis page's own scope (/trends?scope=meter&id=…) reads the tank exactly as the type does.
var trends = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(tank), period) { Bucket = BucketSize.Auto });
var series = trends.SeriesFor(tank)!;
Assert.Equal(BucketSize.Month, trends.Plan.Size);
Assert.Equal(BucketStatus.Unresolved, series.Total.Status);
Assert.Equal(ResolutionClass.Coarse, series.Resolution);
// Tanks & consumables: usage is "only coarser data", never an empty state saying there is none.
var consumables = await new ConsumableService(fx, reader, new CostReader(fx, reader, OptionsOf()))
.GetAsync(new ConsumableRequest(period) { Bucket = BucketSize.Month });
var view = Assert.Single(consumables.Tanks, t => t.MeterId == tank);
Assert.Equal(BucketStatus.Unresolved, view.Usage!.Total.Status);
// The Overview's energy-type card reads the same measure.
var overview = await new DashboardService(fx, new CostService(fx, OptionsOf(), clock), clock)
.GetOverviewAsync(period, BucketSize.Month, new ComparisonRequest(ComparisonKind.PreviousYear));
var card = Assert.Single(overview.Quantities.Measures, m => m.Key.EnergyTypeId == type && m.Key.Measure == TotalsMeasure.Use);
Assert.Equal(BucketStatus.Unresolved, card.Total.Status);
Assert.False(overview.HasNoData);
}
// ------------------------------------------------------------------------------------------------ fixture
/// <summary>The reported instance: a dipstick tank and a burner hour counter in one energy type.</summary>
private async Task<(short Type, int Tank, int Burner)> HeatingOilAsync(CostSandbox box)
{
var type = await box.TypeAsync("L");
var tank = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", D(2022, 10, 14), name: "Öltank");
await using (var db = fx.CreateContext())
{
foreach (var (day, litres) in Dipsticks)
{
db.MeterEvents.Add(Event(tank, MeterEventType.TankLevel, day, litres));
}
db.MeterEvents.Add(Event(tank, MeterEventType.Delivery, D(2023, 9, 10), 4000));
await db.SaveChangesAsync();
}
await box.RecomputeAsync(tank);
var burner = await box.MeterAsync(type, MeterMode.RuntimeCounter, "h", D(2022, 10, 14), name: "Brenner");
await box.ReadingsAsync(burner, [.. BurnerHours.Select(r => (Midnight(r.Day.Year, r.Day.Month, r.Day.Day), r.Hours))]);
return (type, tank, burner);
}
private static MeterEvent Event(int meterId, MeterEventType kind, DateOnly day, double amount) =>
new()
{
MeterId = meterId,
EventType = kind,
Time = Midnight(day.Year, day.Month, day.Day).ToUniversalTime(),
Amount = amount,
Unit = "L",
};
private async Task<EnergyAnalysis> LoadAsync(CostSandbox box, short type, string uri)
{
var reader = Reader();
var costs = new CostReader(fx, reader, OptionsOf());
var loader = new EnergyAnalysisLoader(fx, new AnalysisPeriods(reader, costs), reader, costs, new FlowService(fx, OptionsOf()));
var query = AnalysisQuery.Parse(uri, AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type)));
return await loader.LoadAsync(type, query, CostSandbox.Now);
}
private AnalysisReader Reader() => new(fx, OptionsOf());
private static Microsoft.Extensions.Options.IOptions<MeterVaultOptions> OptionsOf() =>
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
/// <summary>The chart the page draws for a measure, to read why it is empty (A-28).</summary>
private static AnalysisChartPlan ChartPlanOf(AnalysisResult result, AnalysisSeries measure) =>
AnalysisChartPlan.Build(result.Plan.Buckets, [AnalysisChartSeries.ForSeries(measure, "Total use")], ChartPalette.For(isDark: true));
}
@@ -171,25 +171,28 @@ public sealed class EnergyPageTests
// ------------------------------------------------------------------------------------------------ changes
/// <summary>The coverage of a whole January that both periods share.</summary>
private static readonly MatchedCoverageResult Matched = new(
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)))]);
/// <summary>A meter that changed from <paramref name="previous"/> to <paramref name="current"/> over that coverage.</summary>
private static AnalysisSeries Changed(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)),
};
[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);
var changes = MeterChanges.Largest([Changed(1, 100, 90), Changed(2, 50, 150), Changed(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);
@@ -197,6 +200,94 @@ public sealed class EnergyPageTests
Assert.Equal(150, changes[0].Previous, 6);
}
[Fact]
public void A_meter_with_data_but_no_comparable_change_is_listed_with_its_value_and_its_reason() => In("en", () =>
{
// The reported case (A-43): a Heizöl type whose burner has hours in both periods and whose tank was read by
// dipstick only in this one. The burner ranks; the tank must still be on the page with its litres and the
// status that says why there is no change — not silently dropped.
var burner = Changed(8, 929, 986) with { Name = "Brenner" };
var tank = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
{
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
var list = MeterChanges.Of([burner, tank], 6);
Assert.Equal([8], list.Ranked.Select(c => c.Series.MeterId!.Value));
var row = Assert.Single(list.Rest);
Assert.Equal(7, row.Series.MeterId);
Assert.Equal(2100, row.Current.Value);
Assert.Equal(BucketStatus.Missing, row.Comparison.Status);
Assert.False(row.Change.IsAvailable);
// The reason is the reader's own wording, not new prose: the comparison side simply has no data.
Assert.Equal("No data", FigureText.Of(row.Comparison).Status);
Assert.False(row.SharesNoDays);
Assert.Equal(2, list.Shown);
Assert.Equal(0, list.Hidden);
Assert.False(list.IsEmpty);
});
[Fact]
public void A_meter_without_data_in_either_period_is_not_listed_at_all() => In("en", () =>
{
var silent = Series(4, "Never read", [Missing()], total: Missing()) with
{
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
var onlyLastYear = Series(5, "Retired", [Missing()], total: Missing()) with
{
Comparison = new SeriesComparison([Available(40)], Available(40), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
var coarser = Series(6, "Monthly only", [Missing()], total: new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution));
var list = MeterChanges.Of([silent, onlyLastYear, coarser], 6);
Assert.Empty(list.Ranked);
Assert.Equal([6, 5], list.Rest.Select(r => r.Series.MeterId!.Value)); // by name: "Monthly only", "Retired"
Assert.Equal("Only coarser data", FigureText.Of(list.Rest[0].Current).Status);
Assert.Equal(40, list.Rest[1].Comparison.Value);
Assert.DoesNotContain(list.Rest, r => r.Series.MeterId == 4);
// Nothing at all to say: the panel is empty, and the page says so in one line instead of an empty table.
Assert.True(MeterChanges.Of([silent], 6).IsEmpty);
});
[Fact]
public void Two_known_values_over_no_shared_days_are_listed_as_not_comparable()
{
var series = Series(7, "Öltank", [Available(2100)], total: Available(2100), unit: "L") with
{
Comparison = new SeriesComparison(
[Available(1800)], Available(1800), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
var row = Assert.Single(MeterChanges.Of([series], 6).Rest);
Assert.True(row.SharesNoDays);
Assert.Equal((2100d, 1800d), (row.Current.Value, row.Comparison.Value));
Assert.False(row.Change.IsAvailable);
}
[Fact]
public void Neither_list_crowds_the_other_out_and_what_is_left_over_is_counted()
{
AnalysisSeries Quiet(int id) => Series(id, $"Q{id}", [Available(id)], total: Available(id)) with
{
Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable),
};
var list = MeterChanges.Of([.. Enumerable.Range(1, 5).Select(i => Changed(i, i * 10, i)), .. Enumerable.Range(10, 5).Select(Quiet)], 2);
// Two ranked and two unranked rows: a long ranking never takes the room of the meters that have no change,
// and the meters it pushed past its own cap queue behind them.
Assert.Equal([5, 4], list.Ranked.Select(c => c.Series.MeterId!.Value));
Assert.Equal([10, 11], list.Rest.Select(r => r.Series.MeterId!.Value));
Assert.Equal(6, list.Hidden);
Assert.Equal(4, list.Shown);
}
// ------------------------------------------------------------------------------------------------ flow words
[Fact]
@@ -37,6 +37,7 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
Tariff[] tariffs =
[
Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.2345, "EUR/m3"),
Tariff(TariffScope.Meter, _meter, TariffComponent.UnitPrice, 1.9999, "EUR/m3", new DateOnly(2023, 1, 1)),
Tariff(TariffScope.EnergyType, _type, TariffComponent.Bonus, 2.3456, "EUR"),
Tariff(TariffScope.EnergyType, _otherType, TariffComponent.UnitPrice, 9.8765, "EUR/kWh"),
];
@@ -67,9 +68,15 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
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);
// A-42: inside a component the newest validity is listed first, so the price in force is the top row.
Assert.True(
scoped.IndexOf("1.9999", StringComparison.Ordinal) < scoped.IndexOf("1.2345", StringComparison.Ordinal),
"The 2023 price must be listed above the 2020 one.");
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);
Assert.True(all.IndexOf("1.9999", StringComparison.Ordinal) < all.IndexOf("1.2345", 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));
@@ -99,6 +106,6 @@ public sealed class AdminPagesRenderTests(TimescaleFixture fx) : IAsyncLifetime
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) };
private static Tariff Tariff(TariffScope scope, int id, TariffComponent component, double value, string unit, DateOnly? from = null) =>
new() { ScopeType = scope, ScopeId = id, Component = component, Value = value, Unit = unit, ValidFrom = from ?? new DateOnly(2020, 1, 1) };
}
@@ -202,13 +202,15 @@ public sealed class MeterDetailServiceTests(TimescaleFixture fx)
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.31, D(2025, 5, 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" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 4, 10), EventType = MeterEventType.Note, Notes = "inside" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "later" });
await db.SaveChangesAsync();
}
@@ -216,16 +218,21 @@ public sealed class MeterDetailServiceTests(TimescaleFixture fx)
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);
// Both lists newest first (A-42): the page draws them as one dated list.
Assert.Equal(["later", "inside"], markers.Events.Select(e => e.Notes));
Assert.False(markers.MoreEvents);
var change = Assert.Single(markers.TariffChanges);
Assert.Equal((TariffScope.Meter, 0.25), (change.Scope, change.Value));
Assert.Equal([D(2025, 5, 1), D(2025, 3, 1)], markers.TariffChanges.Select(t => t.ValidFrom));
Assert.Equal((TariffScope.Meter, 0.25), (markers.TariffChanges[1].Scope, markers.TariffChanges[1].Value));
// The tariff list: the meter's own and its type's (and any global), never another type's.
// The tariff list: the meter's own and its type's (and any global), never another type's — newest first
// inside each scope, so the price that applies now is at the top (A-42).
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.Equal(3, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
Assert.DoesNotContain(tariffs, t => t.Scope == TariffScope.EnergyType && t.ScopeId == other);
Assert.Equal(
[D(2025, 7, 1), D(2025, 5, 1), D(2025, 1, 1)],
tariffs.Where(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type).Select(t => t.ValidFrom));
}
[Fact]
@@ -278,4 +278,35 @@ public sealed class MeterPageLogicTests
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));
}
// -------------------------------------------------------------------------------------------------- markers
[Fact]
public void The_period_markers_are_one_dated_list_newest_first() => In("en", () =>
{
// A-42: events and price changes are drawn as one list, so they interleave by date instead of running down
// twice. On one day the event comes first, then the price that starts with it.
DateTimeOffset At(int month, int day) =>
new DateTimeOffset(2026, month, day, 12, 0, 0, TimeSpan.Zero).ToUniversalTime();
var markers = new MeterMarkers(
[
new EventRow(3, At(5, 20), MeterEventType.Delivery, 2000, null, null, "L", null, null),
new EventRow(2, At(3, 4), MeterEventType.Note, null, null, null, null, "new tenant", null),
],
MoreEvents: false,
[
new TariffRow(1, TariffScope.EnergyType, 3, TariffComponent.UnitPrice, 0.9, "EUR/L", D(2026, 3, 4), null),
new TariffRow(2, TariffScope.Meter, 7, TariffComponent.UnitPrice, 1.1, "EUR/L", D(2026, 1, 1), null),
]);
var list = MeterMarkerList.Of(markers, Berlin, "Heizöl");
Assert.Equal([D(2026, 5, 20), D(2026, 3, 4), D(2026, 3, 4), D(2026, 1, 1)], list.Select(m => m.Day));
Assert.Equal("Delivery: 2,000.0 L", list[0].Text);
Assert.Equal("Note: new tenant", list[1].Text); // the event of 4 March …
Assert.Contains("Heizöl", list[2].Text, StringComparison.Ordinal); // … then the type price starting that day
Assert.Contains("This meter", list[3].Text, StringComparison.Ordinal);
Assert.Empty(MeterMarkerList.Of(MeterMarkers.None, Berlin, "Heizöl"));
});
}