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, "