Files
MeterVault/tests/Integration.Tests/DashboardRenderTests.cs
T
schmidt.florian 1282acf82c SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker
Complete the SDD §8 dashboard views that were deferred at the M5 boundary,
and fix a shipping bug that left the Docker demo empty.

Bug: "Load reference data" created meters/tank/tariffs but imported zero
readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and
never copied into the build stage, so the App csproj's linked Content glob
resolved to nothing at publish time; ReferenceDataImporter then silently
skipped the missing CSVs after already writing its marker meter, leaving the
DB permanently "loaded" but empty.
  - .dockerignore: stop excluding sampledata/
  - Dockerfile: COPY sampledata/ into the build stage
  - ReferenceDataImporter: fail-fast (validate CSVs exist before the marker
    meter) and throw instead of silently skipping a missing file
  - Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData
    (compose METERVAULT_SEED=true) for a one-command populated demo

New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages):
  - §8.4 Solar/PV (/solar): generation from GenerationCounter meters;
    self-consumption / autarky % / self-consumption % / savings derived from
    meters tagged total_load & grid_import via Meter.Meta role config
    (MeterRoles/MeterMeta) — nothing hardcoded by name.
  - §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill
    gauge, deliveries log, burner runtime, effective L/h (fixed/empirical),
    forecast-to-empty, tariff cost, monthly series.
  - §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption,
    source status, tariff timeline, events, measured-vs-estimated markers.
  - Reusable SeriesChart component; nav links; Meters list rows link to detail.

Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the
three panel services compute real figures and the new routes render (108 total,
all green). Live-verified in Docker: seed imports 302 readings / 347 consumption
rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 09:52:11 +02:00

120 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MeterVault.Integration.Tests;
/// <summary>
/// End-to-end M5 check: load the reference dataset, then confirm the dashboard and admin pages
/// render (server prerender) without error and show real data. Cleans up the shared container.
/// </summary>
[Collection("Timescale")]
public sealed class DashboardRenderTests(TimescaleFixture fx)
{
[Fact]
public async Task Pages_render_with_reference_data()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using (var scope = factory.Services.CreateScope())
{
var importer = scope.ServiceProvider.GetRequiredService<ReferenceDataImporter>();
await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures"));
}
try
{
// The import created the reference meters and their normalized consumption.
await using (var db = fx.CreateContext())
{
Assert.Contains(await db.Meters.Select(m => m.Name).ToListAsync(), n => n == "Zähler Haus");
Assert.True(await db.Meters.CountAsync() >= 8);
Assert.True(await db.Consumption.AnyAsync());
// Regression (audit): Wasser is metered (water tariff), the Kosten Wasser column is
// NOT imported, so the category is not double-counted — Dez 2022 = 14 m³ × 5 € = 70 €.
var wasser = await db.CostCategories.FirstAsync(c => c.Name == "Wasser");
var rollup = await new CostService(fx).GetCategoryCostsAsync(
wasser.Id,
new DateTimeOffset(2022, 12, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero));
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
}
// Panel read models compute real figures from the reference data (SDD §8.4–§8.6).
int hausId;
using (var scope = factory.Services.CreateScope())
{
var services = scope.ServiceProvider;
var wide = new DateOnly(1997, 1, 1);
var toEnd = new DateOnly(2027, 1, 1);
var solar = await services.GetRequiredService<SolarService>().GetSummaryAsync(wide, toEnd);
Assert.True(solar.HasGeneration);
Assert.True(solar.Generation > 0);
// Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve.
Assert.True(solar.HasLoadContext);
Assert.NotNull(solar.SelfConsumption);
Assert.NotNull(solar.Savings);
var consumables = await services.GetRequiredService<ConsumableService>().GetConsumablesAsync(wide, toEnd);
var oil = Assert.Single(consumables);
Assert.True(oil.CurrentLevel is > 0);
Assert.NotEmpty(oil.Deliveries);
Assert.True(oil.ConsumptionInRange > 0);
await using var db = fx.CreateContext();
hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync();
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
Assert.NotNull(detail);
Assert.True(detail!.ReadingCount > 0);
Assert.True(detail.TotalConsumption > 0);
}
using var client = factory.CreateClient();
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
overview.EnsureSuccessStatusCode();
var html = await overview.Content.ReadAsStringAsync();
Assert.Contains("Overview", html, StringComparison.Ordinal);
// These labels live only in the rendered-KPI-card branch, so their presence proves the
// summary loaded and the cards rendered (non-ASCII like € is HTML-entity-encoded).
Assert.Contains("This month", html, StringComparison.Ordinal);
Assert.Contains("This year", html, StringComparison.Ordinal);
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
foreach (var path in new[]
{
"/meters", "/trends", "/solar", "/consumables", "/import",
"/admin/tariffs", "/admin/energy-types", $"/meters/{hausId}",
})
{
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode();
}
}
finally
{
await using var db = fx.CreateContext();
await ClearDataAsync(db);
}
}
private static async Task ClearDataAsync(MeterVaultDbContext db)
{
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();
}
}