using MeterVault.App; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Persistence; using Microsoft.AspNetCore.Localization; 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 €. // The app's own service: it reads in the zone the import normalized in (a bare // `new CostService(fx)` reads UTC, the normalizer's default without options). var wasser = await db.CostCategories.FirstAsync(c => c.Name == "Wasser"); using var costScope = factory.Services.CreateScope(); var rollup = await costScope.ServiceProvider.GetRequiredService().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); // Summe Solar is seeded with its calculation written down (D-28): Solar 1 + Solar 2, generation in kWh, // not costed (generation is never billed, A-15). Its links stay flow topology. var byName = await db.Meters.ToDictionaryAsync(m => m.Name); var summe = MeterVault.Core.Analysis.Virtual.VirtualDefinitionJson.Read(byName["Summe Solar"].Meta); Assert.Equal(MeterVault.Core.Analysis.Virtual.VirtualDefinitionReadStatus.Present, summe.Status); Assert.Equal( new[] { byName["Zähler Solar 1"].Id, byName["Zähler Solar 2"].Id }.Order(), summe.Definition!.ReferencedMeterIds); Assert.True(summe.Definition.Formula!.IsPureSum); Assert.Equal(MeterVault.Core.Analysis.QuantityKind.Generation, summe.Definition.ResultKind); Assert.Equal("kWh", summe.Definition.ResultUnit); Assert.Equal(MeterVault.Core.Analysis.Virtual.VirtualCostRule.None, summe.Definition.CostRule); } // The startup conversion has nothing to do for the seeded Summe Solar (D-28). using (var scope = factory.Services.CreateScope()) { await using var db = fx.CreateContext(); var summeSolarId = await db.Meters.Where(m => m.Name == "Summe Solar").Select(m => m.Id).FirstAsync(); var upgrade = await scope.ServiceProvider.GetRequiredService().RunAsync(); Assert.DoesNotContain(summeSolarId, upgrade.Converted); Assert.DoesNotContain(upgrade.NeedsConfiguration, u => u.MeterId == summeSolarId); } // 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); // The specialized views read the whole sheet (1997 – 2026) through the shared readers, in the app's zone. var solarService = services.GetRequiredService(); var sheetYears = MeterVault.Core.Analysis.PeriodResolver.Resolve( MeterVault.Core.Analysis.PeriodPreset.Custom, wide, toEnd.AddDays(-1), DateTimeOffset.UtcNow, solarService.Zone); var solar = Assert.Single((await solarService.GetAsync(new SolarRequest(sheetYears))).Sites); Assert.True(solar.Generation!.Total.Value > 0); // Haus (total consumption) and Netz (grid import) hold their roles, so self-consumption and savings resolve; // nobody exports, which the view names as a role to set up rather than a zero feed-in. Assert.True(solar.SelfConsumption!.Total.Value > 0); Assert.NotNull(solar.Savings!.Total.Cost); Assert.False(solar.RoleOf(MeterVault.Core.Analysis.Quantities.MeterRole.GridExport).IsSet); var consumables = await services.GetRequiredService().GetAsync(new ConsumableRequest(sheetYears)); var oil = Assert.Single(consumables.Tanks); Assert.True(oil.EstimatedNow?.Volume > 0); Assert.NotNull(oil.LastDipstick); Assert.NotEmpty(oil.Deliveries); Assert.True(oil.Usage!.Total.Value > 0); await using var db = fx.CreateContext(); hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync(); // The meter page's identity read carries no figures any more (they come from the analysis reader); its // record tabs page through the rows (D-50). var details = services.GetRequiredService(); var detail = await details.GetAsync(hausId); Assert.NotNull(detail); Assert.True(detail!.HasReadings); Assert.NotNull(detail.LastReading); Assert.Equal("kWh", detail.NormalizedUnit); Assert.True((await details.GetReadingsAsync(hausId, RecordRange.All)).Total > 0); Assert.True((await details.GetConsumptionAsync(hausId, RecordRange.All)).Total > 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); // Summe Solar is drawn from its formula: its incoming edges are its two sources, marked calculated, and // they add up to its own value (D-30). var summeId = await db.Meters.Where(m => m.Name == "Summe Solar").Select(m => m.Id).FirstAsync(); var intoSumme = flow.Links.Where(l => l.To == $"m{summeId}").ToList(); Assert.Equal(2, intoSumme.Count); Assert.All(intoSumme, l => Assert.True(l.IsCalculated)); Assert.Equal(flow.Nodes.Single(n => n.MeterId == summeId).Value, intoSumme.Sum(l => l.Value), 6); Assert.Equal(MeterVault.Infrastructure.Analysis.SeriesBasis.Virtual, flow.MeterFor(summeId)!.Basis); Assert.Equal(await db.Meters.CountAsync(m => m.EnergyTypeId == electricityTypeId), flow.Meters.Count); } 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); // The coverage summary names the latest month with data and what it rests on (D-19): the reference data ends // in May 2026, with meter data and manual costs alike. It is there whatever the clock says. Assert.Contains("Latest month with data: May 2026 (Meter data and manual costs)", html, StringComparison.Ordinal); // A fixed range renders the loaded panels (the default month to date depends on the clock; the frozen-clock // Overview tests cover it): the bill of 2025 is the sheet's, with the change table and the composition. var year = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri("/?from=2025-01-01&to=2025-12-31", UriKind.Relative))); Assert.Contains("Total cost", year, StringComparison.Ordinal); Assert.Matches(@"7,907\.6[3-6] €", year); Assert.Contains("Total (the bill)", year, StringComparison.Ordinal); Assert.Contains("Cost composition", year, StringComparison.Ordinal); // The navigation (D-48): Analysis, the per-type analysis group, the specialized views, data import and the // configuration group. The reference data has generation counters and a tank, so no setup hint shows. Assert.Contains("Analysis", html, StringComparison.Ordinal); Assert.Contains("Specialized views", html, StringComparison.Ordinal); Assert.Contains("Tanks & consumables", html, StringComparison.Ordinal); Assert.Contains("Data import", html, StringComparison.Ordinal); Assert.Contains("Configuration", html, StringComparison.Ordinal); Assert.Contains($"href=\"/energy/{electricityTypeId}\"", html, StringComparison.Ordinal); Assert.DoesNotContain("No generation meter yet", html, StringComparison.Ordinal); Assert.DoesNotContain("No tank set up yet", html, StringComparison.Ordinal); // Dark unless the theme cookie says otherwise, and the toggle names what it does (D-49). Assert.Contains("aria-label=\"Light mode\"", html, StringComparison.Ordinal); using (var lightClient = factory.CreateClient()) { lightClient.DefaultRequestHeaders.Add("Cookie", "mv-theme=light"); var light = await lightClient.GetStringAsync(new Uri("/", UriKind.Relative)); Assert.Contains("aria-label=\"Dark mode\"", light, StringComparison.Ordinal); } // Expanded navigation groups come from their cookie, and the group of the page shown is open whatever it // says (D-48): on /solar with only Configuration remembered, Specialized views opens too. using (var navClient = factory.CreateClient()) { navClient.DefaultRequestHeaders.Add("Cookie", "mv-nav=config"); var solarPage = await navClient.GetStringAsync(new Uri("/solar", UriKind.Relative)); Assert.Equal("false", GroupExpanded(solarPage, "Energy types")); Assert.Equal("true", GroupExpanded(solarPage, "Specialized views")); Assert.Equal("true", GroupExpanded(solarPage, "Configuration")); } Assert.Equal("true", GroupExpanded(html, "Energy types")); Assert.Equal("false", GroupExpanded(html, "Configuration")); // The preference helper ships; the ApexCharts bundles 6.x no longer has are not referenced. var script = System.Text.RegularExpressions.Regex.Match(html, "