using MeterVault.App; using MeterVault.Core.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.Performance; using Microsoft.EntityFrameworkCore; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.Overview; /// /// What one Overview load costs in SQL (D-15, D-56, A-40). The Overview is the widest read in the app — every energy /// type, every meter's series, the bill with its composition and the comparison's bill — and the page builds all of it /// from one . That call must stay a constant handful of statements /// whatever the instance holds: one quantity read, one bill for the period and one for the comparison, each reusing the /// one result for the cards, the change table and the composition. A figure computed twice shows up here as a repeated /// catalog, tariff or manual-cost load long before it shows up as a slow page. /// /// The Overview reads the whole instance, so every test starts from — and leaves — an instance without meters. [Collection("Timescale")] public sealed class OverviewReadBudgetTests(TimescaleFixture fx) : IAsyncLifetime { /// /// What one load with a comparison sends: four for the shared catalog, seven for the quantities, two for the /// manual-cost dates and the energy types, and five for each of the two bills. It is asserted exactly, so any new /// per-type, per-category or per-meter read fails here instead of being measured later. /// private const int Budget = 23; private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear); public async Task InitializeAsync() { await using var db = fx.CreateContext(); await ClearAsync(db); } public async Task DisposeAsync() { await using var db = fx.CreateContext(); await ClearAsync(db); } [Fact] public async Task One_overview_load_prices_the_period_once_and_its_comparison_once() { await LoadReferenceDataAsync(); var dashboard = Dashboard(); // Warm up: the first call of the process compiles EF queries and opens the pool, which sends its own statements. await dashboard.GetOverviewAsync(Preset(PeriodPreset.PreviousYear), BucketSize.Auto, PreviousYear); using var counter = CommandCounter.Start(); var overview = await dashboard.GetOverviewAsync(Preset(PeriodPreset.PreviousYear), BucketSize.Auto, PreviousYear); var statements = counter.Statements; // The figures are the ones OverviewDataTests pins; this test only counts what it took to get them. Assert.Equal(12, overview.Plan.Buckets.Count); Assert.NotNull(overview.PreviousCost); Assert.NotEmpty(overview.CategoryChanges); Assert.NotEmpty(overview.LineChanges); Assert.True( statements.Count == Budget, $"One Overview load sent {statements.Count} statements, not {Budget}:\n{string.Join('\n', statements)}"); // The catalog — meters, tanks, links, rollup states — is loaded once and shared by all three reads (A-40). Assert.Equal(1, Count(statements, "m.initial_baseline")); Assert.Equal(1, Count(statements, "m.meter_id, m.built_at")); // One bill for the period and one for the comparison, each loading tariffs, manual costs and categories once. Assert.Equal(2, Count(statements, "t.valid_from")); Assert.Equal(2, Count(statements, "m.amount, m.category_id")); Assert.Equal(2, Count(statements, "c.color_hex")); // One quantity read for the page, plus the one each bill makes for the meters it prices. Assert.Equal(3, Count(statements, "span_from")); Assert.Equal(3, Count(statements, "r.meter_id, r.month")); // The freshness mark comes from the stored rollup state, so nothing reads the raw hypertable: the seeded // instance has no live source, and an import-only meter's mark is not sampled from recent readings (A-40). Assert.Equal(0, Count(statements, "SELECT m.id, r.time")); } [Fact] public async Task An_overview_without_a_comparison_prices_the_period_once() { await LoadReferenceDataAsync(); var dashboard = Dashboard(); await dashboard.GetOverviewAsync(Preset(PeriodPreset.PreviousYear), BucketSize.Auto, new ComparisonRequest(ComparisonKind.None)); using var counter = CommandCounter.Start(); await dashboard.GetOverviewAsync(Preset(PeriodPreset.PreviousYear), BucketSize.Auto, new ComparisonRequest(ComparisonKind.None)); Assert.Equal(1, Count(counter.Statements, "t.valid_from")); Assert.Equal(1, Count(counter.Statements, "m.amount, m.category_id")); Assert.Equal(1, Count(counter.Statements, "m.initial_baseline")); } private static int Count(IReadOnlyList statements, string fragment) => statements.Count(s => s.Contains(fragment, StringComparison.OrdinalIgnoreCase)); 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 ClearAsync(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(); } }