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:
@@ -0,0 +1,478 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Virtual;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Normalization;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using MeterVault.Integration.Tests.Costing;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using static MeterVault.Integration.Tests.Costing.CostSandbox;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the JSON shape of the read endpoints other systems consume (brief §9.9, note D-45). The analysis
|
||||
/// rework reroutes what feeds them; these contracts are what must not move: every existing field keeps
|
||||
/// its name and JSON type, and new information arrives only as additional fields — the quantity's status,
|
||||
/// kind and unit, the cost's status and missing prices, the summary's percentage applicability and latest month.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The summary covers the whole instance, so every test starts from — and leaves — an instance without meters,
|
||||
/// tariffs or manual costs (like <see cref="DashboardRenderTests"/>). The app runs on the clock of
|
||||
/// <see cref="CostSandbox.Now"/> (19 September 2026, 14:37 Berlin).
|
||||
/// </remarks>
|
||||
[Collection("Timescale")]
|
||||
public sealed class ApiContractTests(TimescaleFixture fx) : IAsyncLifetime
|
||||
{
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
await ClearDataAsync(db);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
await ClearDataAsync(db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consumption_and_cost_keep_their_fields_and_types()
|
||||
{
|
||||
var meterId = await CreateMeterWithTwoMonthsAsync();
|
||||
try
|
||||
{
|
||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
||||
|
||||
using var consumption = await GetJsonAsync(client, $"/api/v1/consumption?meter={meterId}&from=2024-01-01T00:00:00Z&to=2024-03-01T00:00:00Z");
|
||||
var rows = consumption.RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
AssertProperty(row, "period", JsonValueKind.String);
|
||||
AssertProperty(row, "consumption", JsonValueKind.Number);
|
||||
AssertProperty(row, "generation", JsonValueKind.Number);
|
||||
|
||||
// Added (D-45): whether the quantity can be trusted, and what it is in.
|
||||
AssertString(row, "status", "Available");
|
||||
AssertString(row, "issue", "None");
|
||||
AssertString(row, "kind", "Consumption");
|
||||
AssertString(row, "unit", "kWh");
|
||||
}
|
||||
|
||||
Assert.Equal("2024-01-01", rows[0].GetProperty("period").GetString());
|
||||
Assert.Equal(100, rows[0].GetProperty("consumption").GetDouble(), 6);
|
||||
Assert.Equal(50, rows[1].GetProperty("consumption").GetDouble(), 6);
|
||||
|
||||
using var cost = await GetJsonAsync(client, $"/api/v1/cost?meter={meterId}&from=2024-01-01T00:00:00Z&to=2024-03-01T00:00:00Z");
|
||||
var costRows = cost.RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, costRows.Count);
|
||||
foreach (var row in costRows)
|
||||
{
|
||||
AssertProperty(row, "period", JsonValueKind.String);
|
||||
AssertProperty(row, "consumption", JsonValueKind.Number);
|
||||
AssertProperty(row, "generation", JsonValueKind.Number);
|
||||
AssertProperty(row, "cost", JsonValueKind.Number);
|
||||
|
||||
// Added (D-45): the price coverage of the cost.
|
||||
AssertString(row, "costStatus", "Priced");
|
||||
AssertProperty(row, "missingPrices", JsonValueKind.Array);
|
||||
Assert.Empty(row.GetProperty("missingPrices").EnumerateArray());
|
||||
AssertString(row, "status", "Available");
|
||||
}
|
||||
|
||||
Assert.Equal(30, costRows[0].GetProperty("cost").GetDouble(), 6);
|
||||
Assert.Equal(15, costRows[1].GetProperty("cost").GetDouble(), 6);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupAsync(meterId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bounds_with_an_offset_are_accepted()
|
||||
{
|
||||
// A caller in Berlin sends its local midnight. Npgsql only accepts UTC instants for timestamptz,
|
||||
// so the endpoint must convert rather than fail with a server error.
|
||||
var meterId = await CreateMeterWithTwoMonthsAsync();
|
||||
try
|
||||
{
|
||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
||||
|
||||
var from = Uri.EscapeDataString("2024-01-01T00:00:00+01:00");
|
||||
var to = Uri.EscapeDataString("2024-03-01T00:00:00+01:00");
|
||||
using var response = await client.GetAsync(new Uri($"/api/v1/cost?meter={meterId}&from={from}&to={to}", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupAsync(meterId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_price_leaves_cost_at_zero_and_says_why()
|
||||
{
|
||||
// D-38 on the API: cost stays a number, 0 when nothing could be priced, and the new fields say why — a gap in a
|
||||
// priced history (January, before the meter's own price starts) or no tariff at all.
|
||||
await using var box = new CostSandbox(fx);
|
||||
var type = await box.TypeAsync();
|
||||
var gap = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
|
||||
await box.MeterPriceAsync(gap, 0.30, D(2024, 2, 1));
|
||||
var unpricedType = await box.TypeAsync();
|
||||
var unpriced = await box.MonthlyAsync(unpricedType, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
|
||||
|
||||
using var api = new FrozenApi(fx.ConnectionString);
|
||||
var client = api.Client;
|
||||
var rows = (await GetJsonAsync(client, CostUrl(gap))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
var january = rows[0];
|
||||
Assert.Equal(0, january.GetProperty("cost").GetDouble());
|
||||
Assert.Equal(100, january.GetProperty("consumption").GetDouble(), 6);
|
||||
AssertString(january, "costStatus", "PriceGap");
|
||||
var missing = Assert.Single(january.GetProperty("missingPrices").EnumerateArray().ToList());
|
||||
AssertString(missing, "component", "UnitPrice");
|
||||
AssertString(missing, "reason", "PriceGap");
|
||||
AssertString(missing, "scope", "Meter");
|
||||
Assert.Equal(gap, missing.GetProperty("scopeId").GetInt32());
|
||||
Assert.Equal(gap, missing.GetProperty("meterId").GetInt32());
|
||||
AssertString(missing, "firstMonth", "2024-01-01");
|
||||
AssertString(missing, "lastMonth", "2024-01-01");
|
||||
Assert.Equal(JsonValueKind.False, missing.GetProperty("isCredit").ValueKind);
|
||||
|
||||
var february = rows[1];
|
||||
Assert.Equal(15, february.GetProperty("cost").GetDouble(), 6);
|
||||
AssertString(february, "costStatus", "Priced");
|
||||
|
||||
var notPriced = (await GetJsonAsync(client, CostUrl(unpriced))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, notPriced.Count);
|
||||
Assert.All(notPriced, row =>
|
||||
{
|
||||
Assert.Equal(0, row.GetProperty("cost").GetDouble());
|
||||
AssertString(row, "costStatus", "NotPriced");
|
||||
AssertString(Assert.Single(row.GetProperty("missingPrices").EnumerateArray().ToList()), "reason", "NotPriced");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_month_with_only_a_cost_is_a_cost_row_but_no_consumption_row()
|
||||
{
|
||||
// A meter's own standing charge accrues through a reading gap (D-40): February costs 3 € although nothing was
|
||||
// read. /cost reports it with the quantity's status; /consumption, which lists quantities, leaves it out.
|
||||
await using var box = new CostSandbox(fx);
|
||||
var type = await box.TypeAsync();
|
||||
var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100);
|
||||
await box.MeterPriceAsync(meter, 0.30, D(2024, 1, 1));
|
||||
await box.TariffAsync(TariffScope.Meter, meter, TariffComponent.BasePrice, 3, "EUR/month", D(2024, 1, 1));
|
||||
|
||||
using var api = new FrozenApi(fx.ConnectionString);
|
||||
var costs = (await GetJsonAsync(api.Client, CostUrl(meter))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal([33d, 3d], costs.Select(r => Math.Round(r.GetProperty("cost").GetDouble(), 6)));
|
||||
AssertString(costs[1], "status", "Missing");
|
||||
AssertString(costs[1], "costStatus", "Priced");
|
||||
|
||||
var consumption = (await GetJsonAsync(api.Client, ConsumptionUrl(meter))).RootElement.EnumerateArray().ToList();
|
||||
var january = Assert.Single(consumption);
|
||||
AssertString(january, "period", "2024-01-01");
|
||||
Assert.Equal(100, january.GetProperty("consumption").GetDouble(), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_virtual_meter_returns_evaluated_values_with_a_status()
|
||||
{
|
||||
// D-45: a virtual meter used to return nothing; it now returns its formula evaluated month by month, with the
|
||||
// same status semantics as any meter — and its cost by its rule (here its sources' own costs, D-39).
|
||||
await using var box = new CostSandbox(fx);
|
||||
var type = await box.TypeAsync();
|
||||
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
|
||||
var b = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 20, 30);
|
||||
var januaryOnly = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 10);
|
||||
await box.MeterPriceAsync(a, 0.30, D(2024, 1, 1));
|
||||
await box.MeterPriceAsync(b, 0.10, D(2024, 1, 1));
|
||||
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||||
// No cost rule, so nothing but its sources' data can keep a month in the answer.
|
||||
var partial = await box.VirtualAsync(type, $"m{a} + m{januaryOnly}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||||
var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||||
|
||||
using var api = new FrozenApi(fx.ConnectionString);
|
||||
var client = api.Client;
|
||||
var rows = (await GetJsonAsync(client, ConsumptionUrl(sum))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal([120d, 80d], rows.Select(r => Math.Round(r.GetProperty("consumption").GetDouble(), 6)));
|
||||
Assert.All(rows, r => AssertString(r, "status", "Available"));
|
||||
Assert.All(rows, r => AssertString(r, "kind", "Consumption"));
|
||||
|
||||
var costs = (await GetJsonAsync(client, CostUrl(sum))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal([(100 * 0.30) + (20 * 0.10), (50 * 0.30) + (30 * 0.10)], costs.Select(r => Math.Round(r.GetProperty("cost").GetDouble(), 6)));
|
||||
|
||||
// A source without February makes February unknown, never "a + 0" (strict, D-27) — reported, not left out.
|
||||
var strict = (await GetJsonAsync(client, ConsumptionUrl(partial))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, strict.Count);
|
||||
Assert.Equal(110, strict[0].GetProperty("consumption").GetDouble(), 6);
|
||||
AssertString(strict[1], "status", "Missing");
|
||||
AssertString(strict[1], "issue", "MissingSource");
|
||||
Assert.Equal(0, strict[1].GetProperty("consumption").GetDouble());
|
||||
|
||||
// A definition that cannot be evaluated says so in every month.
|
||||
var invalid = (await GetJsonAsync(client, ConsumptionUrl(broken))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, invalid.Count);
|
||||
Assert.All(invalid, r => AssertString(r, "status", "Invalid"));
|
||||
Assert.All(invalid, r => AssertString(r, "issue", "InvalidDefinition"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_meter_that_is_not_costed_or_cannot_be_evaluated_never_reads_as_a_priced_zero()
|
||||
{
|
||||
// A-16: costAvailability exists so the API never turns an unknown cost into a priced 0. A dependency loop, a
|
||||
// ratio that divides by zero and a generation meter have no cost — each says so (costStatus, costAvailability and
|
||||
// the additive costRule/notCosted), while cost itself stays the number 0 (D-45).
|
||||
await using var box = new CostSandbox(fx);
|
||||
var type = await box.TypeAsync();
|
||||
var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50);
|
||||
var b = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 20, 0);
|
||||
var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2024, 1, 1), 250, 200);
|
||||
await box.TypePriceAsync(type, 0.30, D(2023, 1, 1));
|
||||
var one = await box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
|
||||
var two = await box.VirtualAsync(type, $"m{one}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity);
|
||||
await using (var db = fx.CreateContext())
|
||||
{
|
||||
var meter = await db.Meters.FindAsync(one);
|
||||
meter!.Meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{a} + m{two}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var ratio = await box.VirtualAsync(type, $"m{a} / m{b}", QuantityKind.Indicator, "kWh/kWh", VirtualCostRule.None);
|
||||
|
||||
using var api = new FrozenApi(fx.ConnectionString);
|
||||
var client = api.Client;
|
||||
|
||||
// The loop: every month is invalid, and so is its cost — never "Priced, Available".
|
||||
var loop = (await GetJsonAsync(client, CostUrl(one))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, loop.Count);
|
||||
Assert.All(loop, row =>
|
||||
{
|
||||
AssertString(row, "status", "Invalid");
|
||||
Assert.Equal(0, row.GetProperty("cost").GetDouble());
|
||||
AssertString(row, "costStatus", "NotPriced");
|
||||
AssertString(row, "costAvailability", "Invalid");
|
||||
AssertString(row, "costRule", "None");
|
||||
AssertString(row, "notCosted", "NotEvaluable");
|
||||
});
|
||||
|
||||
// The ratio is never costed (D-26); February divides by zero and is invalid.
|
||||
var indicator = (await GetJsonAsync(client, CostUrl(ratio))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, indicator.Count);
|
||||
Assert.All(indicator, row => AssertString(row, "costStatus", "NotPriced"));
|
||||
AssertString(indicator[1], "status", "Invalid");
|
||||
AssertString(indicator[1], "costAvailability", "Invalid");
|
||||
AssertString(indicator[0], "notCosted", "NoCostRule");
|
||||
|
||||
// Generation is never billed (D-34): a known quantity, and no cost — not a priced one.
|
||||
var generation = (await GetJsonAsync(client, CostUrl(pv))).RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, generation.Count);
|
||||
Assert.All(generation, row =>
|
||||
{
|
||||
AssertString(row, "costStatus", "NotPriced");
|
||||
AssertString(row, "costAvailability", "Available");
|
||||
AssertString(row, "costRule", "None");
|
||||
AssertString(row, "notCosted", "Generation");
|
||||
});
|
||||
|
||||
// A priced meter keeps its rule and names no reason.
|
||||
var priced = (await GetJsonAsync(client, CostUrl(a))).RootElement.EnumerateArray().ToList();
|
||||
Assert.All(priced, row =>
|
||||
{
|
||||
AssertString(row, "costStatus", "Priced");
|
||||
AssertString(row, "notCosted", "None");
|
||||
Assert.NotEqual("None", row.GetProperty("costRule").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_summary_keeps_its_fields_and_types()
|
||||
{
|
||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
||||
|
||||
using var summary = await GetJsonAsync(client, "/api/v1/dashboard/summary");
|
||||
var root = summary.RootElement;
|
||||
|
||||
AssertProperty(root, "asOf", JsonValueKind.String);
|
||||
AssertProperty(root, "latestMonthCost", JsonValueKind.Number);
|
||||
foreach (var name in new[] { "month", "year" })
|
||||
{
|
||||
var kpi = root.GetProperty(name);
|
||||
AssertProperty(kpi, "current", JsonValueKind.Number);
|
||||
AssertProperty(kpi, "previous", JsonValueKind.Number);
|
||||
AssertProperty(kpi, "delta", JsonValueKind.Number);
|
||||
AssertProperty(kpi, "deltaPercent", JsonValueKind.Number);
|
||||
AssertProperty(kpi, "direction", JsonValueKind.Number);
|
||||
|
||||
// Added (D-45): whether the percentage means anything. Against a zero baseline it does not.
|
||||
AssertProperty(kpi, "deltaPercentApplicable", JsonValueKind.False);
|
||||
}
|
||||
|
||||
// Added (D-45): the month the latest cost is for — none on an empty instance.
|
||||
AssertProperty(root, "latestMonth", JsonValueKind.Null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_summary_follows_the_bill_and_names_its_latest_month()
|
||||
{
|
||||
// The legacy calendar windows (this month and year to now, against the whole previous ones), priced as the bill:
|
||||
// the grid import is billed, the household meter behind it is not (D-34), and a manual cost counts once.
|
||||
await using var box = new CostSandbox(fx);
|
||||
var type = await box.TypeAsync();
|
||||
var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport));
|
||||
var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad));
|
||||
await box.MonthlyReadingsAsync(grid, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 19), 0]); // 2025: 1,200; 2026 to July: 700; August: 0
|
||||
await box.ReadingsAsync(grid, (Midnight(2026, 9, 10), 1950)); // September to date: 50
|
||||
await box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(300d, 12)]);
|
||||
await box.TypePriceAsync(type, 0.10, D(2025, 1, 1));
|
||||
await box.ManualCostAsync(D(2026, 9, 5), 7);
|
||||
|
||||
using var api = new FrozenApi(fx.ConnectionString);
|
||||
var client = api.Client;
|
||||
var root = (await GetJsonAsync(client, "/api/v1/dashboard/summary")).RootElement;
|
||||
|
||||
AssertString(root, "asOf", "2026-09-19");
|
||||
|
||||
var month = root.GetProperty("month");
|
||||
Assert.Equal(12, month.GetProperty("current").GetDouble(), 6); // 50 × 0.10 + 7
|
||||
Assert.Equal(0, month.GetProperty("previous").GetDouble(), 6); // August: a priced zero
|
||||
Assert.Equal(0, month.GetProperty("deltaPercent").GetDouble());
|
||||
Assert.Equal(JsonValueKind.False, month.GetProperty("deltaPercentApplicable").ValueKind);
|
||||
Assert.Equal(1, month.GetProperty("direction").GetInt32());
|
||||
|
||||
var year = root.GetProperty("year");
|
||||
Assert.Equal(82, year.GetProperty("current").GetDouble(), 6); // 750 × 0.10 + 7
|
||||
Assert.Equal(120, year.GetProperty("previous").GetDouble(), 6); // the grid's 1,200 kWh; not the house's 3,600
|
||||
Assert.Equal(-38, year.GetProperty("delta").GetDouble(), 6);
|
||||
Assert.Equal(-38d / 120 * 100, year.GetProperty("deltaPercent").GetDouble(), 6);
|
||||
Assert.Equal(JsonValueKind.True, year.GetProperty("deltaPercentApplicable").ValueKind);
|
||||
Assert.Equal(-1, year.GetProperty("direction").GetInt32());
|
||||
|
||||
// The latest month with data rests on the grid meter and the manual cost alike (D-19).
|
||||
Assert.Equal(12, root.GetProperty("latestMonthCost").GetDouble(), 6);
|
||||
var latest = root.GetProperty("latestMonth");
|
||||
AssertString(latest, "period", "2026-09-01");
|
||||
AssertString(latest, "basis", "Both");
|
||||
}
|
||||
|
||||
private static string ConsumptionUrl(int meter) => Url("consumption", meter);
|
||||
|
||||
private static string CostUrl(int meter) => Url("cost", meter);
|
||||
|
||||
/// <summary>January and February 2024 by Berlin's local midnights, so there is no partial edge hour.</summary>
|
||||
private static string Url(string endpoint, int meter) =>
|
||||
$"/api/v1/{endpoint}?meter={meter}&from={Uri.EscapeDataString("2024-01-01T00:00:00+01:00")}&to={Uri.EscapeDataString("2024-03-01T00:00:00+01:00")}";
|
||||
|
||||
private static void AssertProperty(JsonElement element, string name, JsonValueKind kind)
|
||||
{
|
||||
Assert.True(element.TryGetProperty(name, out var value), $"missing property '{name}' in {element}");
|
||||
Assert.Equal(kind, value.ValueKind);
|
||||
}
|
||||
|
||||
private static void AssertString(JsonElement element, string name, string expected)
|
||||
{
|
||||
AssertProperty(element, name, JsonValueKind.String);
|
||||
Assert.Equal(expected, element.GetProperty(name).GetString());
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> GetJsonAsync(HttpClient client, string url)
|
||||
{
|
||||
using var response = await client.GetAsync(new Uri(url, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
/// <summary>A kWh meter with imported month rows for January (100) and February (150) and a 0.30 price on the meter.</summary>
|
||||
private async Task<int> CreateMeterWithTwoMonthsAsync()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
await DatabaseSeeder.SeedAsync(db);
|
||||
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
|
||||
var meter = new Meter { Name = $"contract-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Readings.AddRange(
|
||||
new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), Value = 100, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel },
|
||||
new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), Value = 150, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
|
||||
db.Tariffs.Add(new Tariff
|
||||
{
|
||||
ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice,
|
||||
Value = 0.30, Unit = "EUR/kWh", ValidFrom = new DateOnly(2023, 1, 1),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var normalization = new NormalizationService(db, Core.Normalization.NormalizationEngine.CreateDefault(),
|
||||
Microsoft.Extensions.Options.Options.Create(new Infrastructure.Options.MeterVaultOptions { TimeZone = "Europe/Berlin" }));
|
||||
await normalization.RecomputeMeterAsync(meter.Id, null);
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return meter.Id;
|
||||
}
|
||||
|
||||
private async Task CleanupAsync(int meterId)
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
await db.Tariffs.Where(t => t.ScopeType == TariffScope.Meter && t.ScopeId == meterId).ExecuteDeleteAsync();
|
||||
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The app on the frozen clock of <see cref="CostSandbox.Now"/>, with an API key; disposes all it created.</summary>
|
||||
private sealed class FrozenApi : IDisposable
|
||||
{
|
||||
private readonly MeterVaultAppFactory _root;
|
||||
private readonly WebApplicationFactory<Program> _app;
|
||||
|
||||
public FrozenApi(string connectionString)
|
||||
{
|
||||
_root = new MeterVaultAppFactory(connectionString);
|
||||
_app = _root.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(Now))));
|
||||
Client = _app.CreateClient();
|
||||
Client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
||||
}
|
||||
|
||||
public HttpClient Client { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Client.Dispose();
|
||||
_app.Dispose();
|
||||
_root.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user