Files
MeterVault/tests/Integration.Tests/MeterPage/MeterPageLogicTests.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
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.
2026-09-20 12:44:32 +02:00

313 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
// -------------------------------------------------------------------------------------------------- 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"));
});
}