using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
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 MeterVault.Integration.Tests.Costing;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.Overview;
///
/// The Overview's read model (, brief §7.1) on the frozen clock of
/// : the seeded instance's previous year is the sheet's bill with a composition and change
/// rows that add up to it and per-type measures in their own units; this month to date has no data and points at the
/// latest month; a year to date is compared only over what both years cover (D-07); an instance with nothing but manual
/// costs agrees across the bill, the chart buckets, the composition, the rows and the latest month.
///
/// The Overview reads the whole instance, so every test starts from — and leaves — an instance without meters.
[Collection("Timescale")]
public sealed class OverviewDataTests(TimescaleFixture fx) : IAsyncLifetime
{
private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear);
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_previous_year_is_the_sheet_s_bill_and_every_panel_adds_up_to_it()
{
await LoadReferenceDataAsync();
await using var db = fx.CreateContext();
var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Name, m => m.Id);
var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => t.Key, t => (int)t.Id);
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.PreviousYear), BucketSize.Auto, PreviousYear);
// D-44: the 2025 bill is the sheet's Jahreskosten (7,907.64 €), priced (the tank is "not priced", an attention item).
Assert.False(overview.HasNoData);
Assert.Equal(BucketSize.Month, overview.Plan.Size);
Assert.Equal(12, overview.Plan.Buckets.Count);
Assert.Equal(CostStatus.Priced, overview.Cost.Total.Status);
Assert.InRange(overview.Cost.Total.Cost!.Value, 7907.64 - 0.02, 7907.64 + 0.02);
Assert.Equal(overview.Cost.Total.Cost!.Value, overview.Cost.Buckets.Sum(b => b.Cost ?? 0), 6);
// The composition, the category rows and the bill-line rows each add up to the bill (D-42): manual costs once.
var bill = overview.Cost.Total.Cost!.Value;
Assert.Equal(bill, overview.Cost.Composition!.Total.Cost!.Value, 6);
Assert.Equal(bill, overview.CategoryChanges.Sum(r => r.Current?.Cost ?? 0), 6);
Assert.Equal(bill, overview.LineChanges.Sum(r => r.Current?.Cost ?? 0), 6);
Assert.Equal(["Heizung", "Strom", "Wasser"], overview.CategoryChanges.Select(r => r.Name).Order());
Assert.Single(overview.LineChanges, r => r.Kind == OverviewRowKind.ManualCosts);
Assert.Equal(meters["Zähler Netz"], overview.LineChanges.Single(r => r.Kind == OverviewRowKind.Line && r.EnergyTypeId == types["electricity"]).MeterId);
// Compared with the whole of 2024: both years are complete, so the totals are compared as they are (D-07).
Assert.Equal(CostChangeBasis.WholePeriod, overview.CostChange.Basis);
Assert.Equal(overview.PreviousCost!.Total.Cost!.Value, overview.CostChange.Previous!.Value, 6);
Assert.InRange(overview.CostChange.Previous!.Value, 6783.05 - 0.46 - 0.02, 6783.05 - 0.46 + 0.02);
Assert.True(overview.CostChange.Change.PercentApplicable);
Assert.Null(overview.Projection);
// Per type, in its own units, never added across them; a type without meters has no card.
Assert.Equal(["electricity", "water", "heating_oil"], overview.Types.Select(t => types.Single(k => k.Value == t.Type.Id).Key));
var strom = overview.Types.Single(t => t.Type.Id == types["electricity"]);
Assert.Equal(
[(TotalsMeasure.Use, "kWh"), (TotalsMeasure.GridImport, "kWh"), (TotalsMeasure.Generation, "kWh")],
strom.Measures.Select(m => (m.Key.Measure!.Value, m.Unit)));
Assert.All(strom.Measures, m => Assert.Equal(BucketStatus.Available, m.Total.Status));
Assert.Equal(BillingBasis.GridImport, strom.Cost!.Basis);
Assert.Equal(CostChangeBasis.WholePeriod, strom.CostChange.Basis);
var oil = overview.Types.Single(t => t.Type.Id == types["heating_oil"]);
Assert.Equal([TotalsMeasure.Use, TotalsMeasure.Runtime], oil.Measures.Select(m => m.Key.Measure!.Value));
Assert.Equal(["L", "h"], oil.Measures.Select(m => m.Unit));
Assert.Equal(CostStatus.NotPriced, oil.Cost!.Total.Status);
Assert.Null(oil.Cost.Total.Cost);
Assert.Equal(FreshnessState.Historical, strom.Freshness.State);
Assert.Equal(2, overview.EnergyTypes.Count(t => !t.HasMeters));
// The tank's missing price is the attention item, with the tariff deep link (D-52).
Assert.Contains(overview.Cost.Attention, a => a.Kind == CostAttentionKind.MissingPrice && a.Price?.MeterId == meters["Öltank"]);
Assert.Contains(meters["Summe Solar"], overview.MeterNames.Keys);
}
[Fact]
public async Task The_seeded_month_to_date_has_no_data_and_names_the_latest_month_without_showing_it()
{
await LoadReferenceDataAsync();
var query = AnalysisQuery.Default(AnalysisDefaults.Overview);
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.MonthToDate), query.Bucket, query.Comparison);
Assert.True(overview.HasNoData);
Assert.Null(overview.Cost.Total.Cost);
Assert.All(overview.Quantities.Measures, m => Assert.Equal(BucketStatus.Missing, m.Total.Status));
Assert.Equal(D(2026, 5, 31), overview.Availability!.LastDay);
Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Both), overview.Latest);
// "Go to latest data" opens May 2026 as its own period on the Overview (brief §4.3), never silently.
var latest = AnalysisNavigation.LatestData(query, overview.Availability);
Assert.Equal("/?from=2026-05-01&to=2026-05-31", AnalysisLinks.Overview(latest));
var may = await Dashboard().GetOverviewAsync(Custom(D(2026, 5, 1), D(2026, 5, 31)), BucketSize.Auto, PreviousYear);
Assert.False(may.HasNoData);
Assert.NotNull(may.Cost.Total.Cost);
Assert.Equal(may.Cost.Total.Cost!.Value, may.CategoryChanges.Sum(r => r.Current?.Cost ?? 0), 6);
}
[Fact]
public async Task A_year_to_date_is_compared_only_over_the_months_both_years_cover()
{
await LoadReferenceDataAsync();
var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.YearToDate), BucketSize.Auto, PreviousYear);
// 2026 ends with May; the same days of 2025 are complete, so only January to May of both is compared (D-07).
Assert.InRange(overview.Cost.Total.Cost!.Value, 2940.19 - 0.02, 2940.19 + 0.02);
Assert.Equal(BucketStatus.Partial, overview.Cost.Total.Availability);
Assert.Equal(CostChangeBasis.MatchedBuckets, overview.CostChange.Basis);
Assert.Equal((D(2026, 1, 1), D(2026, 5, 31)), (overview.CostChange.Matched.Current!.FirstDay, overview.CostChange.Matched.Current.LastDay));
Assert.Equal((D(2025, 1, 1), D(2025, 5, 31)), (overview.CostChange.Matched.Comparison!.FirstDay, overview.CostChange.Matched.Comparison.LastDay));
var matched = Enumerable.Range(0, 5).Sum(i => overview.PreviousCost!.Buckets[i].Cost ?? 0);
Assert.Equal(matched, overview.CostChange.Previous!.Value, 6);
// A partial year is never projected (D-09).
Assert.Null(overview.Projection);
// Quantities carry the reader's own matched change.
var strom = overview.Types.First();
Assert.All(strom.Measures, m => Assert.True(m.Comparison!.Matched.IsComparable));
}
[Fact]
public async Task A_manual_cost_only_instance_agrees_across_the_overview_s_panels_and_its_latest_month()
{
// Brief §11: no meter at all, only manual costs — the bill, the chart, the composition, the rows and the latest
// month agree, and nothing waits for a meter.
await using var box = new CostSandbox(fx);
var category = await box.CategoryAsync($"Manual {Guid.NewGuid():N}", 100);
await box.ManualCostAsync(D(2026, 3, 5), 100, categoryId: category);
await box.ManualCostAsync(D(2026, 3, 20), 40);
await box.ManualCostAsync(D(2026, 5, 10), 60, categoryId: category);
var dashboard = Dashboard();
var year = await dashboard.GetOverviewAsync(Preset(PeriodPreset.Last12Months), BucketSize.Month, ComparisonRequest.None);
Assert.Empty(year.Types);
Assert.Empty(year.Quantities.Measures);
Assert.False(year.HasNoData);
CostAssert.Priced(200, year.Cost.Total);
Assert.Equal(200, year.Cost.Buckets.Sum(b => b.Cost ?? 0), 6);
Assert.Equal(200, year.Cost.Composition!.Total.Cost!.Value, 6);
Assert.Equal(200, year.CategoryChanges.Sum(r => r.Current?.Cost ?? 0), 6);
Assert.Equal([160d, 40d], year.CategoryChanges.Select(r => r.Current!.Cost!.Value));
var manual = Assert.Single(year.LineChanges);
Assert.Equal(OverviewRowKind.ManualCosts, manual.Kind);
Assert.Equal(200, manual.Current!.Cost!.Value, 6);
Assert.Equal(CostChangeBasis.NoComparison, year.CostChange.Basis);
// The latest month rests on manual costs, and opened as its own period it is May's 60 € in every panel.
Assert.Equal(new LatestPeriod(D(2026, 5, 1), LatestPeriodBasis.Manual), year.Latest);
var month = await dashboard.GetOverviewAsync(Custom(D(2026, 5, 1), D(2026, 5, 31)), BucketSize.Auto, PreviousYear);
CostAssert.Priced(60, month.Cost.Total);
Assert.Equal(60, month.Cost.Buckets.Sum(b => b.Cost ?? 0), 6);
Assert.Equal(60, month.Cost.Composition!.Total.Cost!.Value, 6);
Assert.Equal(60, month.LineChanges.Single().Current!.Cost!.Value, 6);
Assert.Equal(year.Cost.Buckets[year.Plan.Buckets.ToList().FindIndex(b => b.FirstDay == D(2026, 5, 1))].Cost, month.Cost.Total.Cost);
// This month has nothing yet: no data, with the manual costs' dates to go to.
var mtd = await dashboard.GetOverviewAsync(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, PreviousYear);
Assert.True(mtd.HasNoData);
Assert.Equal((D(2026, 3, 5), D(2026, 5, 10)), (mtd.Availability!.FirstDay, mtd.Availability.LastDay));
}
[Fact]
public async Task An_instance_without_category_members_or_tariffs_still_shows_its_quantities()
{
// Brief §7.1: quantity cards never wait for a price or a category; the composition names the missing step.
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2025, 1, 1), 100, 120, 90);
var overview = await Dashboard().GetOverviewAsync(Custom(D(2025, 1, 1), D(2025, 3, 31)), BucketSize.Month, PreviousYear);
var card = Assert.Single(overview.Types);
var use = Assert.Single(card.Measures);
Assert.Equal(310, use.Total.Value!.Value, 6);
Assert.Equal(CostStatus.NotPriced, card.Cost!.Total.Status);
Assert.Equal(CostSetupGap.NoMembers, overview.Setup!.FirstGap);
var composition = overview.Cost.Composition!;
Assert.Equal([meter], composition.Slices.Single(s => s.Kind == CompositionSliceKind.Uncategorized).MeterIds);
Assert.All(composition.Slices.Where(s => s.Kind == CompositionSliceKind.Category), s => Assert.Null(s.Total.Cost));
// Not priced now, and a known 0 € the year before the meter was installed (D-24): no change is stated.
var row = Assert.Single(overview.CategoryChanges);
Assert.Equal((OverviewRowKind.Uncategorized, CostStatus.NotPriced), (row.Kind, row.Current!.Status));
Assert.False(row.Change.Change.IsAvailable);
}
private DashboardService Dashboard()
{
var clock = new FixedTimeProvider(Now);
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId });
return new DashboardService(fx, new CostService(fx, options, clock), clock);
}
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();
}
}