using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Costing;
///
/// The services behind the current pages, rerouted through the analysis reader and the cost engine: the overview's
/// KPIs, breakdown, difference and trend, and the energy page's cost. They keep their
/// signatures and show the corrected bill (D-34 – D-42) on the frozen clock of .
///
///
/// The overview reads the whole instance, so every test starts from — and leaves — an instance
/// without meters, tariffs or manual costs (like ).
///
[Collection("Timescale")]
public sealed class DashboardServicesTests(TimescaleFixture fx) : IAsyncLifetime
{
private const int KostenTotal = 2;
private const int KostenStrom = 4;
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
[Fact]
public async Task The_seeded_overview_follows_the_bill()
{
// D-44 through the legacy services: "this year" is the sheet's 2026 (the data ends in May), "last year" its 2025,
// the latest month with data is May 2026, and the trend, the breakdown and the energy page all add up to the bill.
await LoadReferenceDataAsync();
var (costs, dashboard) = Services();
var sheet = ReadRows(Costs);
var monthly = OracleByMonth(sheet, dateColumn: 0, valueColumn: KostenTotal, firstDataRow: 1);
var summary = await dashboard.GetSummaryAsync(Now);
Assert.Equal(D(2026, 9, 19), summary.AsOf);
Assert.InRange(summary.Year.Current, 2940.19 - 0.02, 2940.19 + 0.02);
Assert.InRange(summary.Year.Previous, 7907.64 - 0.02, 7907.64 + 0.02);
Assert.True(summary.Year.DeltaPercentApplicable);
Assert.Equal((summary.Year.Current - summary.Year.Previous) / summary.Year.Previous * 100, summary.Year.DeltaPercent, 6);
// September and August 2026 have nothing to price: a zero against a zero, with no percentage.
Assert.Equal((0d, 0d), (summary.Month.Current, summary.Month.Previous));
Assert.False(summary.Month.DeltaPercentApplicable);
Assert.Equal(new LatestMonthWithData(D(2026, 5, 1), LatestPeriodBasis.Both), summary.LatestMonth);
Assert.InRange(summary.LatestMonthCost, monthly[D(2026, 5, 1)] - 0.02, monthly[D(2026, 5, 1)] + 0.02);
// The trend is the bill month by month, manual costs included (A09): each month is the sheet's Kosten.
var trend = await dashboard.GetMonthlyTrendAsync(D(2025, 1, 1), D(2026, 1, 1));
Assert.Equal(12, trend.Count);
AssertReconciles(trend.ToDictionary(p => p.Period, p => p.Cost), monthly, 0.02, "trend", minMatches: 12);
Assert.InRange(trend.Sum(p => p.Cost), 7907.64 - 0.02, 7907.64 + 0.02);
// The breakdown is the bill's composition: Strom (Netz × price), Wasser, Heizung — and nothing else priced.
var breakdown = await dashboard.GetCategoryBreakdownAsync(D(2025, 1, 1), D(2026, 1, 1));
Assert.Equal(["Heizung", "Strom", "Wasser"], breakdown.Select(s => s.Name).Order());
Assert.All(breakdown, s => Assert.Equal(CompositionSliceKind.Category, s.Kind));
Assert.InRange(breakdown.Sum(s => s.Cost), 7907.64 - 0.02, 7907.64 + 0.02);
// The energy page's cost is the type's bill, not every electricity meter summed (A04).
await using var db = fx.CreateContext();
var electricity = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
var strom = await costs.GetEnergyTypeCostAsync(electricity, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
var stromSheet = OracleByMonth(sheet, 0, KostenStrom, 1).Where(m => m.Key.Year == 2025).Sum(m => m.Value);
Assert.Equal(CostStatus.Priced, strom.Status);
Assert.InRange(strom.Cost!.Value, stromSheet - 0.02, stromSheet + 0.02);
Assert.Equal(breakdown.Single(s => s.Name == "Strom").Cost, strom.Cost!.Value, 6);
}
[Fact]
public async Task The_breakdown_is_the_bill_s_composition_and_the_trend_adds_up_to_it()
{
await using var box = new CostSandbox(fx);
var (t1, t2) = (await box.TypeAsync(), await box.TypeAsync());
var a = await box.MonthlyAsync(t1, MeterMode.CumulativeCounter, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 20)]);
await box.MonthlyAsync(t2, MeterMode.CumulativeCounter, D(2025, 1, 1), [.. Enumerable.Repeat(50d, 20)]);
await box.TypePriceAsync(t1, 0.10, D(2025, 1, 1));
await box.TypePriceAsync(t2, 0.20, D(2025, 1, 1));
await box.TariffAsync(TariffScope.Global, null, TariffComponent.BasePrice, 5, "EUR/month", D(2025, 1, 1));
var category = await box.CategoryAsync($"A {Guid.NewGuid():N}", 100, meters: [a]);
await box.ManualCostAsync(D(2026, 3, 10), 7);
var (_, dashboard) = Services();
// This year to now (19 September): a and b for January to August (their data ends on 1 September), the global
// standing charge for every day up to today, and the manual cost once.
var standing = (8 * 5) + (19 * 5 / 30d);
var breakdown = await dashboard.GetCategoryBreakdownAsync(D(2026, 1, 1), D(2026, 10, 19));
Assert.Equal(
[(CompositionSliceKind.Uncategorized, (int?)null, 80 + 7d), (CompositionSliceKind.Category, category, 80d), (CompositionSliceKind.StandingCharge, null, Math.Round(standing, 6))],
breakdown.Select(s => (s.Kind, s.CategoryId, Math.Round(s.Cost, 6))));
Assert.Equal(TariffScope.Global, breakdown[2].StandingCharge!.Scope);
Assert.Equal(string.Empty, breakdown[0].Name);
var summary = await dashboard.GetSummaryAsync(Now);
Assert.Equal(summary.Year.Current, breakdown.Sum(s => s.Cost), 6);
Assert.Equal(12 * 25d, summary.Year.Previous, 6);
// The trend: 2024 lies before both meters' install dates, so the bill of each of its months is a known zero (D-24)
// — a point at 0, not a gap. The months of this year add up to the year.
var trend = await dashboard.GetMonthlyTrendAsync(D(2024, 1, 1), D(2026, 10, 19));
Assert.Equal(D(2024, 1, 1), trend[0].Period);
Assert.Equal(33, trend.Count);
Assert.All(trend.Where(p => p.Period.Year == 2024), p => Assert.Equal(0, p.Cost));
Assert.Equal(25, trend.Single(p => p.Period == D(2025, 1, 1)).Cost, 6);
Assert.Equal(32, trend.Single(p => p.Period == D(2026, 3, 1)).Cost, 6);
Assert.Equal(19 * 5 / 30d, trend[^1].Cost, 6);
Assert.Equal(summary.Year.Current, trend.Where(p => p.Period.Year == 2026).Sum(p => p.Cost), 6);
}
[Fact]
public async Task A_month_nothing_was_measured_in_is_not_a_point_on_the_trend()
{
// A meter without an install date is unknown before its first reading, not zero; its type's standing charge only
// starts with its service. Those months are no points — not the known 0 the engine gives a charge not yet due.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await box.ReadingsAsync(
meter, (new DateTimeOffset(2026, 2, 1, 8, 0, 0, TimeSpan.FromHours(1)), 0), (Midnight(2026, 3, 1), 100), (Midnight(2026, 4, 1), 250));
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
await box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 5, "EUR/month", D(2025, 1, 1));
var (_, dashboard) = Services();
var trend = await dashboard.GetMonthlyTrendAsync(D(2025, 6, 1), D(2026, 5, 1));
// February: 100 kWh and the charge from the first data day; March: 150 kWh and the charge; April: the charge alone
// (the service runs on through a reading gap, D-40).
Assert.Equal(
[(D(2026, 2, 1), 15d), (D(2026, 3, 1), 20d), (D(2026, 4, 1), 5d)],
trend.Select(p => (p.Period, Math.Round(p.Cost, 6))));
}
[Fact]
public async Task The_difference_view_compares_the_same_elapsed_part_of_last_year()
{
// 10 kWh a day in 2025, 12 in 2026. This year to 19 September 14:37 is set against 1 January – 19 September 14:37
// of 2025 (D-06), not against whole months or the whole year: 261 days each.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2025, 1, 1));
var readings = new List<(DateTimeOffset, double)>();
var register = 0d;
for (var day = D(2025, 1, 1); day < D(2026, 9, 19); day = day.AddDays(1))
{
register += day.Year == 2025 ? 10 : 12;
var next = day.AddDays(1);
readings.Add((Midnight(next.Year, next.Month, next.Day), register));
}
await box.ReadingsAsync(meter, [.. readings]);
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
var category = await box.CategoryAsync($"D {Guid.NewGuid():N}", 100, meters: [meter]);
var (_, dashboard) = Services();
var rows = await dashboard.GetCategoryDifferenceAsync(D(2026, 1, 1), D(2025, 1, 1), D(2026, 10, 19));
var row = Assert.Single(rows);
Assert.Equal(category, row.CategoryId);
Assert.Equal(261 * 12 * 0.10, row.Current, 6);
Assert.Equal(261 * 10 * 0.10, row.Previous, 6);
Assert.True(row.DeltaPercentApplicable);
Assert.Equal(20, row.DeltaPercent, 6);
}
[Fact]
public async Task An_energy_type_costs_its_billed_meters_and_a_meter_its_own_rule()
{
// A04: the energy page summed every meter of the type — the grid, the house behind it and a subsection of the
// house. The type's bill is the grid import (D-34); each meter's own cost stays available as a view.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
var car = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), [.. Enumerable.Repeat(50d, 12)]);
var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2025, 1, 1), [.. Enumerable.Repeat(250d, 12)]);
await box.MonthlyReadingsAsync(grid, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 12)]);
await box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(300d, 12)]);
await box.LinkAsync(house, car);
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
var (costs, _) = Services();
var bill = await costs.GetEnergyTypeCostAsync(type, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
Assert.Equal(1200 * 0.10, bill.Cost!.Value, 6);
var houseCosts = await costs.GetMeterCostsAsync(house, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
Assert.Equal(12, houseCosts.Count);
Assert.All(houseCosts, c => Assert.Equal((300d, 30d), (c.Consumption, Math.Round(c.Cost, 6))));
// A generation meter reports generation, and is not costed.
var generation = await costs.GetMeterCostsAsync(pv, Midnight(2025, 1, 1), Midnight(2026, 1, 1));
Assert.All(generation, c => Assert.Equal((0d, 250d, 0d, QuantityKind.Generation), (c.Consumption, c.Generation, c.Cost, c.Kind)));
}
private (CostService Costs, DashboardService Dashboard) Services()
{
var clock = new FixedTimeProvider(Now);
var costs = new CostService(fx, Options(), clock);
return (costs, new DashboardService(fx, costs, clock));
}
private static Microsoft.Extensions.Options.IOptions Options() =>
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" });
private async Task LoadReferenceDataAsync()
{
await using var db = fx.CreateContext();
var importer = new ReferenceDataImporter(db, new ImportService(db, Normalization(db)), new CsvImporter());
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync();
await db.ManualCosts.ExecuteDeleteAsync();
await db.CostCategoryMembers.ExecuteDeleteAsync();
await db.Tariffs.ExecuteDeleteAsync();
await db.Tanks.ExecuteDeleteAsync();
await db.MeterSources.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.ImportBatches.ExecuteDeleteAsync();
}
}