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