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