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