Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s
ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
@@ -38,12 +38,38 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
|
||||
// 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");
|
||||
var rollup = await new CostService(fx).GetCategoryCostsAsync(
|
||||
using var costScope = factory.Services.CreateScope();
|
||||
var rollup = await costScope.ServiceProvider.GetRequiredService<CostService>().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<MeterVault.Infrastructure.Analysis.VirtualDefinitionUpgrade>().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).
|
||||
@@ -55,26 +81,37 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
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);
|
||||
// The specialized views read the whole sheet (1997 – 2026) through the shared readers, in the app's zone.
|
||||
var solarService = services.GetRequiredService<SolarService>();
|
||||
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<ConsumableService>().GetConsumablesAsync(wide, toEnd);
|
||||
var oil = Assert.Single(consumables);
|
||||
Assert.True(oil.CurrentLevel is > 0);
|
||||
var consumables = await services.GetRequiredService<ConsumableService>().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.ConsumptionInRange > 0);
|
||||
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();
|
||||
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
|
||||
// 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<MeterDetailService>();
|
||||
var detail = await details.GetAsync(hausId);
|
||||
Assert.NotNull(detail);
|
||||
Assert.True(detail!.ReadingCount > 0);
|
||||
Assert.True(detail.TotalConsumption > 0);
|
||||
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();
|
||||
@@ -82,6 +119,16 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
.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();
|
||||
@@ -90,11 +137,60 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
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);
|
||||
// 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, "<script src=\"(metervault[^\"]*[.]js)\"").Groups[1].Value;
|
||||
Assert.NotEmpty(script);
|
||||
Assert.Contains("setPreference", await client.GetStringAsync(new Uri("/" + script, UriKind.Relative)), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("apex-charts.min.js", html, StringComparison.Ordinal);
|
||||
|
||||
// The configuration page for energy types says it edits definitions, not the analysis.
|
||||
var definitions = await client.GetStringAsync(new Uri("/admin/energy-types", UriKind.Relative));
|
||||
Assert.Contains("Energy type definitions", definitions, StringComparison.Ordinal);
|
||||
|
||||
foreach (var path in new[]
|
||||
{
|
||||
@@ -108,6 +204,36 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// The energy type page (brief §7.3): titled with the type's own name, one card per measure — never the old
|
||||
// "flow" title or a top-level throughput that added supply to use — the bill named by its basis, and the
|
||||
// Overview | History | Flow | Meters tabs; the Flow tab manages connections and has its table equivalent.
|
||||
// A fixed year of the reference data, so the cards render whatever today is.
|
||||
var energyPage = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
|
||||
new Uri($"/energy/{electricityTypeId}?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains(">Strom</h1>", energyPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Total use", energyPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Grid import", energyPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Counted: Zähler Solar 1, Zähler Solar 2", energyPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Billed by grid import (Zähler Netz)", energyPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Meters (6)", energyPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Strom flow", energyPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Top-level throughput", energyPage, StringComparison.Ordinal);
|
||||
var flowTab = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri($"/energy/{electricityTypeId}?tab=flow&from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains("Manage connections", flowTab, StringComparison.Ordinal);
|
||||
Assert.Contains("The flow as a table", flowTab, StringComparison.Ordinal);
|
||||
Assert.Contains("Input of a calculated sum", flowTab, StringComparison.Ordinal);
|
||||
var metersTab = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri($"/energy/{electricityTypeId}?tab=meters&from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains("Breakdown of a counted meter", metersTab, StringComparison.Ordinal);
|
||||
// ...each meter link carrying the page's period into the meter's Analysis tab.
|
||||
Assert.Contains($"href=\"/meters/{hausId}?tab=analysis&from=2025-01-01&to=2025-12-31\"", metersTab, StringComparison.Ordinal);
|
||||
|
||||
// The meter list shares the type tab's list: what each meter measured and how it counts, the calculated Summe
|
||||
// Solar marked as an analysis-only view, each name opening the meter's Analysis tab.
|
||||
var meterList = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri("/meters", UriKind.Relative)));
|
||||
Assert.Contains("Counts as", meterList, StringComparison.Ordinal);
|
||||
Assert.Contains("Analysis only", meterList, StringComparison.Ordinal);
|
||||
Assert.Contains($"href=\"/meters/{hausId}?tab=analysis\"", meterList, StringComparison.Ordinal);
|
||||
|
||||
// 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)))
|
||||
@@ -118,9 +244,39 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
Assert.Contains("Record event", meterPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Edit meter", meterPage, StringComparison.Ordinal);
|
||||
|
||||
// The tab bar sits under the header, keyed tabs (D-47), with the analysis in the default tab: the quality
|
||||
// section only renders once the meter's analysis loaded (prerendered, D-46).
|
||||
Assert.Contains("Normalized data", meterPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Data quality and coverage", meterPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Meter register details", meterPage, StringComparison.Ordinal);
|
||||
|
||||
// A deep link into a tab and an action renders — the action itself only opens once the
|
||||
// page is interactive, which a prerender request never is.
|
||||
(await client.GetAsync(new Uri($"/meters/{hausId}?tab=events&action=swap", UriKind.Relative))).EnsureSuccessStatusCode();
|
||||
// page is interactive, which a prerender request never is. The events tab it names is the one shown.
|
||||
var swapLink = await client.GetStringAsync(new Uri($"/meters/{hausId}?tab=events&action=swap", UriKind.Relative));
|
||||
Assert.Contains("Record a meter swap or a counter reset here", swapLink, StringComparison.Ordinal);
|
||||
|
||||
// Old links keep working: the legacy consumption tab opens Normalized data.
|
||||
var legacyTab = await client.GetStringAsync(new Uri($"/meters/{hausId}?tab=consumption", UriKind.Relative));
|
||||
Assert.Contains("what charts, totals and costs are built from", legacyTab, StringComparison.Ordinal);
|
||||
|
||||
// A virtual meter gets the same analysis from its formula (no register details, no readings), and its
|
||||
// Sources link opens the Calculation tab with the formula's meters by name.
|
||||
int summePageId;
|
||||
await using (var db = fx.CreateContext())
|
||||
{
|
||||
summePageId = await db.Meters.Where(m => m.Name == "Summe Solar").Select(m => m.Id).FirstAsync();
|
||||
}
|
||||
|
||||
var virtualPage = System.Net.WebUtility.HtmlDecode(
|
||||
await client.GetStringAsync(new Uri($"/meters/{summePageId}", UriKind.Relative)));
|
||||
Assert.Contains("Data quality and coverage", virtualPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Calculation", virtualPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Add reading", virtualPage, StringComparison.Ordinal);
|
||||
var calculation = System.Net.WebUtility.HtmlDecode(
|
||||
await client.GetStringAsync(new Uri($"/meters/{summePageId}?tab=sources", UriKind.Relative)));
|
||||
Assert.Contains("Meters in the formula", calculation, StringComparison.Ordinal);
|
||||
Assert.Contains("Zähler Solar 1", calculation, StringComparison.Ordinal);
|
||||
Assert.Contains("Zähler Solar 2", calculation, StringComparison.Ordinal);
|
||||
|
||||
// The way back from setting up a connector for a source: the connector page names the meter
|
||||
// and links to its source dialog, and lists which meters each connector serves.
|
||||
@@ -136,9 +292,36 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
var importPage = await client.GetStringAsync(new Uri("/import", UriKind.Relative));
|
||||
Assert.Contains($"href=\"/meters/{hausId}\"", importPage, StringComparison.Ordinal);
|
||||
|
||||
// Regression: /trends started with its load guard set, so it never left the spinner.
|
||||
var trends = await client.GetStringAsync(new Uri("/trends", UriKind.Relative));
|
||||
Assert.Contains("Total over range", trends, StringComparison.Ordinal);
|
||||
// The Analysis page (/trends, brief §7.4) renders its figures in the prerender — it used to never leave the
|
||||
// spinner. The portfolio cost of 2025 is the sheet's Jahreskosten (D-44, 7.907,64 € ± 0,02 €), manual Heizung
|
||||
// costs included once; a category whose meters measure different things is explained, not charted as one
|
||||
// quantity; and two meters compare side by side.
|
||||
var trends = System.Net.WebUtility.HtmlDecode(
|
||||
await client.GetStringAsync(new Uri("/trends?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains(">Analysis</h1>", trends, StringComparison.Ordinal);
|
||||
Assert.Contains("Total cost", trends, StringComparison.Ordinal);
|
||||
Assert.Contains("7,907.65", trends, StringComparison.Ordinal);
|
||||
Assert.Contains("Manual costs", trends, StringComparison.Ordinal);
|
||||
Assert.Contains("Values per period", trends, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Total over range", trends, StringComparison.Ordinal);
|
||||
|
||||
int stromCategoryId, netzId;
|
||||
await using (var db = fx.CreateContext())
|
||||
{
|
||||
stromCategoryId = await db.CostCategories.Where(c => c.Name == "Strom").Select(c => c.Id).FirstAsync();
|
||||
netzId = await db.Meters.Where(m => m.Name == "Zähler Netz").Select(m => m.Id).FirstAsync();
|
||||
}
|
||||
|
||||
var mixedCategory = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
|
||||
new Uri($"/trends?scope=category&id={stromCategoryId}&metric=consumption", UriKind.Relative)));
|
||||
Assert.Contains("measure different things", mixedCategory, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Values per period", mixedCategory, StringComparison.Ordinal);
|
||||
|
||||
var meterComparison = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(
|
||||
new Uri($"/trends?scope=meters&ids={hausId},{netzId}&from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains("Zähler Haus", meterComparison, StringComparison.Ordinal);
|
||||
Assert.Contains("Zähler Netz", meterComparison, StringComparison.Ordinal);
|
||||
Assert.Contains("Values per period", meterComparison, StringComparison.Ordinal);
|
||||
|
||||
// A tank's page leads with the entry that drives it (a tank level), not a reading nothing reads.
|
||||
int tankId;
|
||||
@@ -151,6 +334,22 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
Assert.Contains("Record tank level", tankPage, StringComparison.Ordinal);
|
||||
var consumablesPage = await client.GetStringAsync(new Uri("/consumables", UriKind.Relative));
|
||||
Assert.Contains($"/meters/{tankId}?tab=events&action=delivery", consumablesPage, StringComparison.Ordinal);
|
||||
// The tank's state now stays apart from the selected period (D-54): the last dipstick as measured, the contents
|
||||
// estimated from it, and a forecast that is a projection or says why there is none.
|
||||
Assert.Contains("Last dipstick", consumablesPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Estimated now (incl. deliveries since)", consumablesPage, StringComparison.Ordinal);
|
||||
Assert.Contains("Selected period", consumablesPage, StringComparison.Ordinal);
|
||||
|
||||
// A year of the sheet on Solar: the figures with their units, and a setup card for the one role nobody holds
|
||||
// (grid export) that names it in words — never the raw role tokens of the old hint.
|
||||
var solarYear = System.Net.WebUtility.HtmlDecode(
|
||||
await client.GetStringAsync(new Uri("/solar?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains("Self-consumption", solarYear, StringComparison.Ordinal);
|
||||
Assert.Contains("kWh", solarYear, StringComparison.Ordinal);
|
||||
Assert.Contains("Grid export: no meter yet", solarYear, StringComparison.Ordinal);
|
||||
Assert.Contains($"/meters/{hausId}?tab=analysis", solarYear, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("total_load", solarYear, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("grid_import", solarYear, StringComparison.Ordinal);
|
||||
|
||||
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
|
||||
// this covers the labels that only exist once rows have rendered — the branch a
|
||||
@@ -167,8 +366,24 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
await germanClient.GetStringAsync(new Uri("/", UriKind.Relative)));
|
||||
Assert.Contains("lang=\"de\"", germanOverview, StringComparison.Ordinal);
|
||||
Assert.Contains("Übersicht", germanOverview, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("This month", germanOverview, StringComparison.Ordinal);
|
||||
Assert.Contains("Spezialansichten", germanOverview, StringComparison.Ordinal);
|
||||
Assert.Contains("Konfiguration", germanOverview, StringComparison.Ordinal);
|
||||
Assert.Contains("Datenimport", germanOverview, StringComparison.Ordinal);
|
||||
Assert.Contains("Letzter Monat mit Daten: Mai 2026", germanOverview, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Latest month with data", germanOverview, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Total cost", germanOverview, StringComparison.Ordinal);
|
||||
|
||||
// MudBlazor's own accessible names speak German too (brief §8): a nav group's toggle, not "Toggle …".
|
||||
Assert.Contains("aria-label=\"Energiearten ein- oder ausklappen\"", germanOverview, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("aria-label=\"Toggle ", germanOverview, StringComparison.Ordinal);
|
||||
|
||||
// The Analysis page in German: the same 2025 bill, in German words and number format.
|
||||
var germanTrends = System.Net.WebUtility.HtmlDecode(
|
||||
await germanClient.GetStringAsync(new Uri("/trends?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains("Gesamtkosten", germanTrends, StringComparison.Ordinal);
|
||||
Assert.Contains("7.907,65", germanTrends, StringComparison.Ordinal);
|
||||
Assert.Contains("Manuelle Kosten", germanTrends, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Total cost", germanTrends, StringComparison.Ordinal);
|
||||
|
||||
// Meter names are user data: they stay exactly as imported, in either language. The
|
||||
// meter list is where they render — the overview shows cost categories, not meters.
|
||||
@@ -178,6 +393,16 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
// ...while the meter's mode, which is an enum and not user data, is translated.
|
||||
Assert.Contains("Zählerstand (kumulativ)", germanMeters, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("CumulativeCounter", germanMeters, StringComparison.Ordinal);
|
||||
Assert.Contains("Zählt als", germanMeters, StringComparison.Ordinal);
|
||||
|
||||
// The energy type page in German: the measures and tabs are worded, the type's name is not.
|
||||
var germanEnergy = System.Net.WebUtility.HtmlDecode(
|
||||
await germanClient.GetStringAsync(new Uri($"/energy/{electricityTypeId}?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains(">Strom</h1>", germanEnergy, StringComparison.Ordinal);
|
||||
Assert.Contains("Gesamtverbrauch", germanEnergy, StringComparison.Ordinal);
|
||||
Assert.Contains("Netzbezug", germanEnergy, StringComparison.Ordinal);
|
||||
Assert.Contains("Abrechnung nach Netzbezug", germanEnergy, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Total use", germanEnergy, StringComparison.Ordinal);
|
||||
|
||||
foreach (var path in new[]
|
||||
{
|
||||
@@ -190,6 +415,16 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
var response = await germanClient.GetAsync(new Uri(path, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// The specialized views in German: the roles and figures in words, the tank's two parts labelled.
|
||||
var germanSolar = System.Net.WebUtility.HtmlDecode(
|
||||
await germanClient.GetStringAsync(new Uri("/solar?from=2025-01-01&to=2025-12-31", UriKind.Relative)));
|
||||
Assert.Contains("Eigenverbrauch", germanSolar, StringComparison.Ordinal);
|
||||
Assert.Contains("Netzeinspeisung: noch kein Zähler", germanSolar, StringComparison.Ordinal);
|
||||
var germanConsumables = System.Net.WebUtility.HtmlDecode(
|
||||
await germanClient.GetStringAsync(new Uri("/consumables", UriKind.Relative)));
|
||||
Assert.Contains("Letzte Peilung", germanConsumables, StringComparison.Ordinal);
|
||||
Assert.Contains("Gewählter Zeitraum", germanConsumables, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -198,6 +433,11 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The <c>aria-expanded</c> of a navigation group's toggle in rendered HTML.</summary>
|
||||
private static string GroupExpanded(string html, string group) =>
|
||||
System.Text.RegularExpressions.Regex.Match(
|
||||
html, "aria-expanded=\"(true|false)\" aria-label=\"Toggle " + System.Text.RegularExpressions.Regex.Escape(group) + "\"").Groups[1].Value;
|
||||
|
||||
private static async Task ClearDataAsync(MeterVaultDbContext db)
|
||||
{
|
||||
await db.MeterLinks.ExecuteDeleteAsync();
|
||||
|
||||
Reference in New Issue
Block a user