using MeterVault.App; using MeterVault.App.Analysis; using MeterVault.App.Energy; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Dashboard; using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData; namespace MeterVault.Integration.Tests.Analysis; /// /// The energy type page and the meter lists without a database (brief §7.3): the tab and view keys, which metrics a type /// charts, how a meter counts in words (a breakdown names its parent, a calculated view its sources), meter rows that say /// "No data" instead of a made-up zero, the largest changes over matched coverage, the flow table's words, and the /// connection rules — no meter into itself, no duplicate, no loop, no silent change to a calculation. /// public sealed class EnergyPageTests { // ------------------------------------------------------------------------------------------------ keys [Theory] [InlineData("http://x/energy/3", "overview", "total")] [InlineData("http://x/energy/3?tab=history", "history", "total")] [InlineData("http://x/energy/3?tab=HISTORY&view=Meters", "history", "meters")] [InlineData("http://x/energy/3?tab=flow&period=ytd", "flow", "total")] [InlineData("http://x/energy/3?tab=meters&view=bogus", "meters", "total")] [InlineData("http://x/energy/3?tab=sankey", "overview", "total")] [InlineData("/energy/3?view=meters#top", "overview", "meters")] public void Tab_and_view_keys_resolve_with_fallbacks(string uri, string tab, string view) { var (resolvedTab, resolvedView) = EnergyPageKeys.Parse(uri); Assert.Equal(tab, resolvedTab); Assert.Equal(view, resolvedView); Assert.Equal(AnalysisLinks.EnergyTabs.ToList().IndexOf(tab), AnalysisLinks.EnergyTabIndex(resolvedTab)); } [Fact] public void The_tab_and_view_are_not_part_of_the_analysis_state() { // A tab or view switch must not reload the analysis (D-46): the parsed query is the same with or without them. var defaults = AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(3)); var plain = AnalysisQuery.Parse("/energy/3?period=ytd", defaults); var tabbed = AnalysisQuery.Parse("/energy/3?tab=history&view=meters&period=ytd", defaults); Assert.Equal(plain, tabbed); Assert.Equal("/energy/3?tab=history&period=ytd", AnalysisLinks.EnergyType(3, AnalysisLinks.EnergyTabHistory, plain)); } // ------------------------------------------------------------------------------------------------ metrics [Fact] public void A_type_charts_the_metrics_of_its_measures_and_meters_and_its_cost() { var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31)); var use = Measure(TotalsMeasure.Use, QuantityKind.Consumption, buckets); var generation = Measure(TotalsMeasure.Generation, QuantityKind.Generation, buckets); var net = Series(9, "Balance", [Available(1), Available(-2), Available(3)], kind: QuantityKind.Net); var result = Result(buckets, [net], [generation, use]); var metrics = EnergyMetrics.Available(result, cost: null); Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Net], metrics); Assert.Equal(AnalysisMetric.Consumption, EnergyMetrics.Effective(null, metrics)); Assert.Equal(AnalysisMetric.Generation, EnergyMetrics.Effective(AnalysisMetric.Generation, metrics)); Assert.Equal(AnalysisMetric.Consumption, EnergyMetrics.Effective(AnalysisMetric.Balance, metrics)); Assert.Equal([use], EnergyMetrics.MeasuresOf(result, AnalysisMetric.Consumption)); Assert.Equal([net], EnergyMetrics.MetersOf(result, AnalysisMetric.Net)); Assert.Empty(EnergyMetrics.MeasuresOf(result, AnalysisMetric.Net)); } // ------------------------------------------------------------------------------------------------ memberships [Fact] public void A_breakdown_names_its_parent_and_a_calculated_view_its_sources() => In("en", () => { var names = new Dictionary { [1] = "Haus", [4] = "Solar 1", [5] = "Solar 2" }; string Name(int id) => names[id]; var breakdown = MeterMembership.Of(Entry(3, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1]), null, Name)!; Assert.Equal("Breakdown of a counted meter", breakdown.Label); Assert.Equal("Part of Haus: shown, but never added on top.", breakdown.Detail); Assert.False(breakdown.IsCounted); var sum = Series(9, "Summe Solar", [Available(1)], kind: QuantityKind.Generation) with { Virtual = new VirtualSeriesInfo(VirtualMeterStatus.Valid, "m4 + m5", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None, [4, 5], [4, 5], [], null, null), }; var view = MeterMembership.Of(Entry(9, MeterTotalsClass.AnalysisOnly, MeterTotalsReason.VirtualView), sum, Name)!; Assert.Equal("Analysis only", view.Label); Assert.Contains("Solar 1, Solar 2", view.Detail, StringComparison.Ordinal); var grid = MeterMembership.Of(Entry(2, MeterTotalsClass.GridImport, MeterTotalsReason.GridImportRole, measure: TotalsMeasure.GridImport), null, Name)!; Assert.True(grid.IsCounted); Assert.Contains("never added", grid.Detail, StringComparison.Ordinal); Assert.Null(MeterMembership.Of(null, null, Name)); }); [Fact] public void Membership_wording_is_german_in_german() => In("de", () => { var membership = MeterMembership.Of(Entry(3, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1]), null, _ => "Zähler Haus")!; Assert.Equal("Aufschlüsselung eines gezählten Zählers", membership.Label); Assert.Equal("Teil von Zähler Haus: wird gezeigt, aber nie zusätzlich addiert.", membership.Detail); }); // ------------------------------------------------------------------------------------------------ meter rows [Fact] public void A_meter_without_data_reads_no_data_and_a_measured_zero_reads_zero() => In("en", () => { var buckets = Buckets(D(2025, 1, 1), D(2025, 2, 28)); var silent = Series(1, "Silent", [Missing(), Missing()], BucketValue.Missing()); var idle = Series(2, "Idle", [Available(0), Available(0)], Available(0)); var result = Result(buckets, [silent, idle], []) with { Classification = [ new MeterClassification(1, "Silent", Entry(1, MeterTotalsClass.Use, MeterTotalsReason.ConsumptionRoot, measure: TotalsMeasure.Use)), new MeterClassification(2, "Idle", Entry(2, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, parents: [1])), ], }; var rows = MeterListRows.Build([Facts(2, "Idle"), Facts(1, "Silent"), Facts(3, "Added later")], result); Assert.Equal(["Added later", "Idle", "Silent"], rows.Select(r => r.Meter.Name)); var silentRow = rows.Single(r => r.Meter.Id == 1); Assert.Equal("No data", silentRow.ValueText); Assert.False(silentRow.HasValue); Assert.Equal("No data covers this period", silentRow.QualityDetail); var idleRow = rows.Single(r => r.Meter.Id == 2); Assert.Equal("0 kWh", idleRow.ValueText); Assert.True(idleRow.HasValue); Assert.Equal("Part of Silent: shown, but never added on top.", idleRow.Membership!.Detail); // A meter the read did not cover has no figure at all, not a zero. var later = rows.Single(r => r.Meter.Id == 3); Assert.Equal(Format.Unknown, later.ValueText); Assert.Null(later.Membership); }); [Fact] public void Meter_rows_search_names_serials_types_modes_and_membership() => In("en", () => { var row = new MeterListRow(Facts(1, "Zähler Haus") with { SerialNumber = "SN-77", Location = "Keller" }, null, new MeterMembership(MeterTotalsClass.Breakdown, "Breakdown of a counted meter", string.Empty), null); Assert.True(MeterListRows.Matches(row, null)); Assert.True(MeterListRows.Matches(row, " haus ")); Assert.True(MeterListRows.Matches(row, "sn-7")); Assert.True(MeterListRows.Matches(row, "keller")); Assert.True(MeterListRows.Matches(row, "strom")); Assert.True(MeterListRows.Matches(row, "cumulative")); Assert.True(MeterListRows.Matches(row, "breakdown")); Assert.False(MeterListRows.Matches(row, "wasser")); }); [Fact] public void Quick_entry_is_a_reading_a_tank_level_or_nothing_for_a_calculation() { Assert.Equal(MeterLinks.QuickEntry(1, MeterMode.CumulativeCounter), MeterListRows.QuickEntry(Facts(1, "A"))!.Value.Href); Assert.True(MeterListRows.QuickEntry(Facts(2, "Tank") with { Mode = MeterMode.ConsumableBalance })!.Value.IsTank); Assert.Null(MeterListRows.QuickEntry(Facts(3, "Sum") with { Mode = MeterMode.Virtual })); } // ------------------------------------------------------------------------------------------------ changes /// The coverage of a whole January that both periods share. 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)))]); /// A meter that changed from to over that coverage. 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 notComparable = Series(9, "Not comparable", [Available(5)]) with { Comparison = new SeriesComparison([Missing()], Missing(), MatchedCoverageResult.NotComparable, null, null, Change.Unavailable), }; 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); Assert.Equal(50, changes[0].Current, 6); 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] public void The_flow_table_words_every_ribbon_and_every_meter_it_does_not_draw() => In("en", () => { var graph = new FlowGraph(1, "Strom", "kWh", 100, [ new FlowNode("m1", "Haus", 100, 0, null, false, 1), new FlowNode("m2", "Auto", 30, 1, null, false, 2), new FlowNode("other1", "Haus", 70, 1, null, true, null), ], [ new FlowLink("m1", "m2", 30), new FlowLink("m1", "other1", 70), new FlowLink("m4", "m9", 10) { IsCalculated = true }, new FlowLink("m3", "m1", 5) { IsEstimated = true }, new FlowLink("m3", "m2", 5) { IsEstimated = true, IsCapped = true }, ]); Assert.Equal("Other (Haus)", FlowText.NodeName(graph, "other1")); Assert.Equal("Measured part", FlowText.EdgeKind(graph, graph.Links[0])); Assert.Equal("Not measured by a meter below it", FlowText.EdgeKind(graph, graph.Links[1])); Assert.Equal("Input of a calculated sum", FlowText.EdgeKind(graph, graph.Links[2])); Assert.StartsWith("Estimated share", FlowText.EdgeKind(graph, graph.Links[3]), StringComparison.Ordinal); Assert.StartsWith("Estimated, capped", FlowText.EdgeKind(graph, graph.Links[4]), StringComparison.Ordinal); var water = new FlowMeter(7, "Water", 7, BucketStatus.Available, ValueIssue.None, QuantityKind.Consumption, "m³", SeriesBasis.Physical, false); var difference = new FlowMeter(8, "A − B", -50, BucketStatus.Available, ValueIssue.None, QuantityKind.Net, "kWh", SeriesBasis.Virtual, false); var silent = new FlowMeter(9, "Silent", null, BucketStatus.Missing, ValueIssue.NoCoverage, QuantityKind.Consumption, "kWh", SeriesBasis.Physical, false); Assert.Equal("Not drawn: measured in m³", FlowText.NotDrawnReason(graph, water)); Assert.Equal("Not drawn: a calculation that is not a plain sum", FlowText.NotDrawnReason(graph, difference)); Assert.Null(FlowText.NotDrawnReason(graph, water with { InDiagram = true })); // Signed stays signed, and a meter without a number says so instead of "0". Assert.Equal("-50 kWh", FlowText.MeterValue(difference)); Assert.Equal("No data", FlowText.MeterValue(silent)); }); // ------------------------------------------------------------------------------------------------ connection rules [Fact] public void A_connection_is_refused_into_itself_across_types_twice_or_around_a_loop() { var a = Link(1, "A"); var b = Link(2, "B"); var c = Link(3, "C"); var foreign = Link(4, "Foreign") with { EnergyTypeId = 2 }; (int, int)[] links = [(1, 2), (2, 3)]; Assert.Equal(MeterLinkRefusal.SameMeter, MeterLinkRules.CheckAdd(a, a, links, 1, 1).Refusal); Assert.Equal(MeterLinkRefusal.UnknownMeter, MeterLinkRules.CheckAdd(a, null, links, 1, 99).Refusal); Assert.Equal(MeterLinkRefusal.OtherEnergyType, MeterLinkRules.CheckAdd(a, foreign, links, 1, 4).Refusal); Assert.Equal(MeterLinkRefusal.AlreadyLinked, MeterLinkRules.CheckAdd(a, b, links, 1, 2).Refusal); // C → A would close A → B → C → A: refused, with the existing path from A to C. var loop = MeterLinkRules.CheckAdd(c, a, links, 3, 1); Assert.Equal(MeterLinkRefusal.WouldCreateCycle, loop.Refusal); Assert.Equal([1, 2, 3], loop.Path); Assert.Equal(MeterLinkRefusal.WouldCreateCycle, MeterLinkRules.CheckAdd(b, a, links, 2, 1).Refusal); // A shortcut in the same direction is no loop. Assert.True(MeterLinkRules.CheckAdd(a, c, links, 1, 3).IsAllowed); } [Fact] public void A_virtual_meter_calculated_from_its_links_keeps_them() { var source = Link(1, "Solar 1"); var legacy = Link(9, "Legacy sum") with { Mode = MeterMode.Virtual, CalculatedFromLinks = true }; var defined = Link(10, "Summe Solar") with { Mode = MeterMode.Virtual, ReferencedMeterIds = [1] }; Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, MeterLinkRules.CheckAdd(source, legacy, [], 1, 9).Refusal); Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, MeterLinkRules.CheckRemove(legacy).Refusal); // A stored calculation is the formula's, never the links': those stay free to edit. Assert.True(MeterLinkRules.CheckAdd(source, defined, [], 1, 10).IsAllowed); Assert.True(MeterLinkRules.CheckRemove(defined).IsAllowed); var topology = new MeterLinkTopology(1, [source, defined], [new MeterLinkEntry(5, 1, 10)], new Dictionary(), [(1, 10)]); Assert.True(topology.MirrorsCalculation(topology.Links[0])); } [Fact] public void A_refusal_is_worded_with_the_meters_named() => In("en", () => { var names = new Dictionary { [1] = "Haus", [2] = "Auto", [3] = "Wallbox" }; var loop = new MeterLinkCheck(MeterLinkRefusal.WouldCreateCycle, [1, 2, 3]); Assert.Equal("This would make a loop: Haus → Auto → Wallbox → Haus.", FlowText.Refusal(loop, id => names[id], 1)); Assert.Contains("Wallbox is calculated from its connections", FlowText.Refusal(MeterLinkCheck.Refused(MeterLinkRefusal.CalculatedFromLinks), id => names[id], 3), StringComparison.Ordinal); Assert.Equal(string.Empty, FlowText.Refusal(MeterLinkCheck.Allowed, id => names[id], 1)); }); [Fact] public void Describing_a_meter_reads_whether_its_calculation_is_stored() { var legacy = new Meter { Id = 9, Name = "Legacy", EnergyTypeId = 1, Mode = MeterMode.Virtual, Unit = "kWh", Meta = "{}" }; var defined = new Meter { Id = 10, Name = "Sum", EnergyTypeId = 1, Mode = MeterMode.Virtual, Unit = "kWh", Meta = MeterVault.Core.Analysis.Virtual.VirtualDefinitionJson.Write( "{}", new MeterVault.Core.Analysis.Virtual.VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", MeterVault.Core.Analysis.Virtual.VirtualCostRule.None)), }; var physical = new Meter { Id = 4, Name = "Solar 1", EnergyTypeId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh", Meta = "{}" }; Assert.True(MeterLinkRules.Describe(legacy).CalculatedFromLinks); Assert.False(MeterLinkRules.Describe(defined).CalculatedFromLinks); Assert.Equal([4, 5], MeterLinkRules.Describe(defined).ReferencedMeterIds); Assert.False(MeterLinkRules.Describe(physical).CalculatedFromLinks); } // ------------------------------------------------------------------------------------------------ helpers private static AnalysisSeries Measure(TotalsMeasure measure, QuantityKind kind, IReadOnlyList buckets) => new(SeriesKey.ForMeasure(1, measure, "kWh"), string.Empty, SeriesBasis.Measure, kind, "kWh", [.. buckets.Select(_ => Available(1))], Available(buckets.Count), IsAdditive: true); private static AnalysisResult Result(IReadOnlyList buckets, IReadOnlyList series, IReadOnlyList measures) { var period = Range(buckets[0].FirstDay, buckets[^1].EndDay.AddDays(-1)); var plan = BucketPlanner.Plan(period, BucketSize.Month); return new AnalysisResult(new AnalysisRequest(AnalysisScope.ForEnergyType(1), period), plan, series, measures, ScopeAvailability.None, []); } private static MeterTotalsEntry Entry( int meterId, MeterTotalsClass @class, MeterTotalsReason reason, TotalsMeasure? measure = null, IReadOnlyList? parents = null) => new(meterId, 1, @class, reason, @class, measure, parents ?? [], [], null, null); private static MeterFacts Facts(int id, string name) => new(id, name, 1, "Strom", MeterMode.CumulativeCounter, "kWh", IsActive: true, null, null, HasTank: false); private static MeterLinkMeter Link(int id, string name) => new(id, name, 1, MeterMode.CumulativeCounter, true, false, []); }