using MeterVault.App; using MeterVault.App.Analysis; using MeterVault.App.Components.Pages.Overview; using MeterVault.App.Localization; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Dashboard; using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData; namespace MeterVault.Integration.Tests.Overview; /// /// The Overview's rules without a database (brief §7.1): a cost change is stated over what both periods cover /// completely (D-07) — the whole totals when both are complete, else the paired buckets both have, else not at all; a /// projection only for a complete month or year to date after enough days (D-09); the words and links of its rows; and /// the specific wording of invalid calculations, totals conflicts and overlap hints in attention items (D-53). /// public sealed class OverviewLogicTests { private static readonly AttentionNames Names = new( new Dictionary { [1] = "Haus", [2] = "Netz", [4] = "Solar 1", [5] = "Solar 2", [9] = "Summe Solar" }, new Dictionary { [1] = "Strom" }); [Fact] public void Complete_totals_are_compared_as_a_whole() { var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31)); var pairs = Pairs(buckets); var now = Priced(buckets, 100, 0.30); var before = Priced(buckets, 80, 0.30); var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, pairs); Assert.Equal(CostChangeBasis.WholePeriod, change.Basis); Assert.False(change.IsPartial); Assert.Equal(90, change.Current!.Value, 6); Assert.Equal(72, change.Previous!.Value, 6); Assert.Equal(18, change.Change.Absolute!.Value, 6); Assert.Equal(25, change.Change.Percent!.Value, 6); Assert.True(change.Matched.IsContiguous); Assert.Equal((D(2025, 1, 1), D(2025, 3, 31)), (change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)); Assert.Equal((D(2024, 1, 1), D(2024, 3, 31)), (change.Matched.Comparison!.FirstDay, change.Matched.Comparison.LastDay)); } [Fact] public void A_partial_period_is_compared_over_the_buckets_both_have_complete() { // January and March are complete on both sides; February is only partly covered now: it is left out of the // change, which is then stated over two separate stretches. var buckets = Buckets(D(2025, 1, 1), D(2025, 3, 31)); var now = Figures(buckets, (100, BucketStatus.Available), (50, BucketStatus.Partial), (100, BucketStatus.Available)); var before = Figures(buckets, (80, BucketStatus.Available), (80, BucketStatus.Available), (120, BucketStatus.Available)); var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, Pairs(buckets)); Assert.Equal(CostChangeBasis.MatchedBuckets, change.Basis); Assert.True(change.IsPartial); Assert.Equal(60, change.Current!.Value, 6); Assert.Equal(60, change.Previous!.Value, 6); Assert.Equal(0, change.Change.Direction); Assert.Equal(2, change.Matched.Pieces.Count); Assert.False(change.Matched.IsContiguous); Assert.Equal(D(2025, 3, 31), change.Matched.Current!.LastDay); } [Fact] public void Nothing_complete_on_both_sides_is_not_comparable_and_no_comparison_is_nothing_at_all() { var buckets = Buckets(D(2025, 1, 1), D(2025, 2, 28)); var now = Figures(buckets, (100, BucketStatus.Partial), (100, BucketStatus.Available)); var before = Figures(buckets, (100, BucketStatus.Available), (100, BucketStatus.Missing)); var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, Pairs(buckets)); Assert.Equal(CostChangeBasis.NotComparable, change.Basis); Assert.False(change.Change.IsAvailable); Assert.False(change.Matched.IsComparable); var none = OverviewComparison.Between(now.Totals, now.Total, null, null, []); Assert.Equal(CostChangeBasis.NoComparison, none.Basis); } [Fact] public void A_zero_baseline_has_a_change_but_no_percentage() { var buckets = Buckets(D(2025, 1, 1), D(2025, 1, 31)); var now = Priced(buckets, 100, 0.30); var before = Priced(buckets, 0, 0.30); var change = OverviewComparison.Between(now.Totals, now.Total, before.Totals, before.Total, Pairs(buckets)); Assert.Equal(30, change.Change.Absolute!.Value, 6); Assert.False(change.Change.PercentApplicable); Assert.Equal("percentage not applicable", In("en", () => Format.ChangePercent(change.Change))); } [Fact] public void A_month_to_date_is_projected_only_from_a_complete_figure_after_a_week() { // 1 – 19 September 14:37: 18.6 days elapsed of 30. 10 kWh a day at 0.30 € and 3 € a month standing charge, plus a // one-off manual cost of 50 € that is not drawn on. var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, Now, Berlin); var elapsed = (period.Now - period.From).TotalDays; var total = MonthFigure(period, usagePerDay: 10, elapsed, standingPerMonth: 3, manual: 50); var projection = OverviewProjection.For(period, total); Assert.NotNull(projection); Assert.Equal(18, projection!.Days); var running = total.Usage!.Value + total.StandingCharge!.Value; Assert.Equal((running / elapsed * 30) + 50, projection.Value, 6); // Not after only a few days, not from a partial figure, never for a complete or a custom period. var early = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, new DateTimeOffset(2026, 9, 5, 12, 0, 0, TimeSpan.FromHours(2)), Berlin); Assert.Null(OverviewProjection.For(early, MonthFigure(early, 10, (early.Now - early.From).TotalDays, 3, 0))); Assert.Null(OverviewProjection.For(period, MonthFigure(period, 10, elapsed, 3, 0, BucketStatus.Partial))); Assert.Null(OverviewProjection.For(PeriodResolver.Resolve(PeriodPreset.PreviousYear, null, null, Now, Berlin), total)); Assert.Null(OverviewProjection.For(PeriodResolver.Resolve(PeriodPreset.Last12Months, null, null, Now, Berlin), total)); } [Fact] public void Rows_are_named_in_the_readers_language_and_link_to_their_scope_with_the_same_dates() => In("de", () => { var query = AnalysisQuery.Default(AnalysisDefaults.Overview).WithPeriod(PeriodPreset.PreviousYear); var none = CostChange.NoComparison; var category = new OverviewChangeRow(OverviewRowKind.Category, "Strom", null, null, none) { CategoryId = 4 }; Assert.Equal("Strom", OverviewText.NameOf(category)); Assert.Equal("/trends?scope=category&id=4&metric=cost&period=prev-year", OverviewText.HrefOf(category, query)); var uncategorized = new OverviewChangeRow(OverviewRowKind.Uncategorized, string.Empty, null, null, none); Assert.Equal("Ohne Kategorie", OverviewText.NameOf(uncategorized)); Assert.Null(OverviewText.HrefOf(uncategorized, query)); var typeCharge = new OverviewChangeRow(OverviewRowKind.StandingCharge, "Strom", null, null, none) { StandingCharge = new StandingChargeKey(TariffScope.EnergyType, 1), EnergyTypeId = 1, }; Assert.Equal("Grundpreis — Strom", OverviewText.NameOf(typeCharge)); Assert.Equal("/energy/1?period=prev-year", OverviewText.HrefOf(typeCharge, query)); var global = new OverviewChangeRow(OverviewRowKind.StandingCharge, string.Empty, null, null, none) { StandingCharge = new StandingChargeKey(TariffScope.Global, null) }; Assert.Equal("Grundpreis — global", OverviewText.NameOf(global)); var credit = new OverviewChangeRow(OverviewRowKind.Line, "Netz", null, null, none) { MeterId = 2, LineKind = BillLineKind.FeedIn }; Assert.Equal("Einspeisevergütung", OverviewText.DetailOf(credit)); Assert.Equal("/meters/2?tab=analysis&period=prev-year", OverviewText.HrefOf(credit, query)); var manual = new OverviewChangeRow(OverviewRowKind.ManualCosts, string.Empty, null, null, none); Assert.Equal("Manuelle Kosten", OverviewText.NameOf(manual)); }); [Fact] public void An_invalid_calculation_says_what_is_wrong_and_about_which_meters() => In("en", () => { var unknown = new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.UnknownMeter, [12], []), }; var item = AttentionItems.ForProblem(unknown, Names); Assert.Equal( "Summe Solar: the calculation is invalid, so no values can be shown. The formula refers to a meter that does not exist (Meter #12).", item.Text); Assert.Equal("Edit calculation", item.ActionText); var cycle = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.DependencyCycle, [9, 4, 9], []) }, Names); Assert.EndsWith("(Summe Solar → Solar 1 → Summe Solar).", cycle.Text, StringComparison.Ordinal); var units = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.UnitMismatch, [4, 5], ["kWh", "m³"]) }, Names); Assert.EndsWith("The formula adds or subtracts meters in different units (kWh, m³).", units.Text, StringComparison.Ordinal); var kinds = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.KindMismatch, [4, 1], ["Generation", "Consumption"]) }, Names); Assert.EndsWith("(Generation, Consumption).", kinds.Text, StringComparison.Ordinal); // Without the validator's finding the item stays general. Assert.Equal( "Summe Solar: the calculation is invalid, so no values can be shown.", AttentionItems.ForProblem(new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9), Names).Text); }); [Fact] public void Calculation_findings_are_worded_in_german() => In("de", () => { var item = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, 9) { Virtual = new VirtualProblem(VirtualProblemKind.SelfReference, [9], []) }, Names); Assert.Equal( "Summe Solar: Die Berechnung ist ungültig, daher können keine Werte angezeigt werden. Die Formel verweist auf den Zähler selbst (Summe Solar).", item.Text); }); [Fact] public void A_totals_conflict_and_a_billing_workaround_say_what_contradicts_itself() => In("en", () => { var duplicate = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.TotalsProblem, 2) { Totals = new TotalsProblem(TotalsProblemKind.DuplicateRole, 2, 1, MeterRole.GridImport) }, Names); Assert.Equal( "Netz and Haus: the totals configuration contradicts itself. Two meters hold the same role at the same time; the one created first keeps it (Grid import).", duplicate.Text); var loop = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.TotalsProblem, 1) { Totals = new TotalsProblem(TotalsProblemKind.ContainmentCycle, 1) }, Names); Assert.Equal("Haus: the totals configuration contradicts itself. The meter links form a loop.", loop.Text); var billing = AttentionItems.ForCost( new CostAttention(CostAttentionKind.BillingConfiguration, 5) { Totals = new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, 5, 1) }, Names); Assert.StartsWith("Solar 2: the billing setup does not fit and was worked around. The meter has its own price, but its unit", billing.Text, StringComparison.Ordinal); Assert.EndsWith("(Haus).", billing.Text, StringComparison.Ordinal); }); [Fact] public void An_overlap_hint_of_a_kind_the_page_does_not_know_still_says_what_it_is() => In("de", () => { Assert.Equal( "Der Zähler ist nicht verknüpft und wird als Teil des Gesamtverbrauchs gezählt", OverlapHintKind.NotLinkedBelowTotalLoad.Display()); var hint = AttentionItems.ForProblem( new AnalysisProblem(AnalysisProblemKind.PossibleOverlap, 2) { Hint = new OverlapHint((OverlapHintKind)99, 1, 2, 1) }, Names); Assert.Equal("Netz: 99", hint.Text); Assert.Equal("/energy/1?tab=meters", hint.ActionHref); }); [Fact] public void The_chart_selection_is_read_from_the_address() { Assert.Null(OverviewView.ChartKeyOf("http://localhost/")); Assert.Null(OverviewView.ChartKeyOf("http://localhost/?period=ytd")); Assert.Equal("t1:use:kWh", OverviewView.ChartKeyOf("http://localhost/?period=ytd&chart=t1%3Ause%3AkWh#top")); Assert.Equal("#0af", OverviewDonutSlice.SafeColor("#0af")); Assert.Null(OverviewDonutSlice.SafeColor("red; background:url(x)")); Assert.Equal("var(--mud-palette-info)", OverviewDonutSlice.PaletteVariable(2)); } private static IReadOnlyList Pairs(IReadOnlyList buckets) { var period = Range(buckets[0].FirstDay, buckets[^1].EndDay.AddDays(-1)); var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear)); return ComparisonResolver.PairBuckets(period, resolution.Period!, buckets); } /// One line at 0.30 €/kWh whose monthly quantity and availability are given per bucket. private static CostResult Figures(IReadOnlyList buckets, params (double Amount, BucketStatus Availability)[] months) { var parts = CostCalculator.Parts(buckets); var quantities = parts.Select(p => months[p.BucketIndex].Availability == BucketStatus.Missing ? CostQuantity.Unknown(p) : CostQuantity.Known(p, months[p.BucketIndex].Amount, months[p.BucketIndex].Availability)).ToList(); return CostCalculator.Calculate(new CostRequest( buckets, D(2039, 12, 31), TariffBook.Create([Price(TariffComponent.UnitPrice, 0.30, "EUR/kWh")], "EUR"), [new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities)])); } /// A month to date: kWh a day at 0.30 €, a monthly standing charge and a manual cost. private static CostAmount MonthFigure( ResolvedPeriod period, double usagePerDay, double elapsed, double standingPerMonth, double manual, BucketStatus availability = BucketStatus.Available) { var plan = BucketPlanner.Plan(period, BucketSize.Month); var parts = CostCalculator.Parts(plan.Buckets); var quantities = parts.Select(p => CostQuantity.Known(p, usagePerDay * elapsed, availability)).ToList(); var today = PeriodResolver.LocalDate(period.Now, period.Zone); ManualCost[] manualCosts = manual > 0 ? [new ManualCost { Id = 1, PeriodStart = period.FirstDay.AddDays(2), PeriodEnd = period.FirstDay.AddDays(2), Amount = manual }] : []; var result = CostCalculator.Calculate(new CostRequest( plan.Buckets, today, TariffBook.Create([Price(TariffComponent.UnitPrice, 0.30, "EUR/kWh"), Price(TariffComponent.BasePrice, standingPerMonth, "EUR/month", id: 2)], "EUR"), [new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities)], [StandingChargeScope.ForEnergyType(1, new ServicePeriod(D(2020, 1, 1)))], manualCosts)); return result.Total; } private static Tariff Price(TariffComponent component, double value, string unit, int id = 1) => new() { Id = id, ScopeType = TariffScope.EnergyType, ScopeId = 1, Component = component, Value = value, Unit = unit, ValidFrom = D(2000, 1, 1), }; }