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.
313 lines
16 KiB
C#
313 lines
16 KiB
C#
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"));
|
||
});
|
||
}
|