af786c7b28
ci / build-test (push) Successful in 1m37s
Entering a reading by hand previously meant POST /api/v1/readings with an API key, or a one-row CSV through the import wizard. SourceType.Manual existed in the enum but nothing was behind it. This adds the click path, built for the case it is actually used in: walking to each manual meter with a phone in hand. "Add reading" on the Readings tab opens a dialog prefilled with the meter's last register value and the current local time, both editable: - An on-screen keypad, because a register is read standing at the meter. It behaves like a calculator against the prefill - the first digit replaces it (a fresh register), while backspace edits it in place, which is the common case since only a register's last digits move. - Typed input accepts both separators (last one wins), so a German and an English phone keyboard both do the right thing. ReadingEntry owns that rule and is unit-tested; it deliberately differs from GermanNumber, where a lone dot really is a thousands separator. - A live parsed-value echo plus delta-since-last, which is the net that catches a mistyped digit before it is committed. - Decrease / replaces-existing / future / backdated surfaced before saving, and DST spring-forward gaps refused rather than shifted. The verdict line sits in a fixed-height, no-wrap slot above the keypad. That is load-bearing, not cosmetic: an alert that appears there when the value dips below the last reading moves the keys out from under the user's thumb mid-entry, which is a guaranteed mistype on a phone. The long-form explanation goes below the keypad, where reflow is harmless. Saving goes through IngestionService.IngestByMeterAsync, so the monotonic-decrease guard and inline renormalization apply exactly as for any other ingest. A new optional quality parameter stamps the row ReadingQuality.Manual; null preserves today's behaviour, so a source re-reporting the same timestamp updates the value without silently relabelling a hand-entered or imported reading. Also: the meter-detail tabs now render times in the instance timezone per SDD section 10, instead of raw UTC. Without it a reading entered at 18:00 reads back as 16:00. Side effect is that historic imported monthly rows show 01:00/02:00 rather than 00:00 - correct, if noisier. Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
137 lines
6.6 KiB
C#
137 lines
6.6 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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<ReferenceDataImporter>();
|
||
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<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);
|
||
|
||
var consumables = await services.GetRequiredService<ConsumableService>().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<MeterDetailService>().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<FlowService>()
|
||
.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();
|
||
}
|
||
}
|