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; /// /// 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. /// [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(); 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; short electricityTypeId; 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().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().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().GetAsync(hausId); Assert.NotNull(detail); Assert.True(detail!.ReadingCount > 0); Assert.True(detail.TotalConsumption > 0); // Flow graph: the demo Haus → Auto chain yields a link + an "Other (Haus)" remainder. electricityTypeId = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync(); var flow = await services.GetRequiredService() .GetFlowAsync(electricityTypeId, new DateOnly(1997, 1, 1), new DateOnly(2027, 1, 1)); Assert.True(flow.HasChain); Assert.Contains(flow.Nodes, n => n.IsOther); } 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", "/admin/categories", "/admin/connectors", "/admin/settings", $"/meters/{hausId}", $"/energy/{electricityTypeId}", }) { var response = await client.GetAsync(new Uri(path, UriKind.Relative)); response.EnsureSuccessStatusCode(); } // Manual entry is reachable without an API key or a CSV: the Readings tab of a real // (non-virtual) meter offers it, prefilled with that meter's last register value. var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative))) .Content.ReadAsStringAsync(); Assert.Contains("Add reading", meterPage, StringComparison.Ordinal); } finally { await using var db = fx.CreateContext(); await ClearDataAsync(db); } } 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(); } }