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 [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); 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); } // ------------------------------------------------------------------------------------------------ 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, []); }