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