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; /// /// 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. /// 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")); }); }