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