95c51842e8
ci / build-test (push) Successful in 1m23s
The headline tiles were lifetime consumption, a raw reading count and the register span. None of those answer why someone opens a meter: how much this month, more or less than last, where the year lands, what it costs. A cumulative counter's register value is an accident of when the meter was installed. MeterPeriodService buckets consumption by calendar month in the instance timezone -- via date_trunc(... AT TIME ZONE) rather than EF grouping, because a reading at 00:30 local on 1 January is 23:30 on 31 December in UTC and would be booked to the wrong month (SDD §10). It reports generation for a generation counter and consumption otherwise, so a PV meter stops claiming it consumed 0 kWh. Month- and year-to-date are compared against a projection of the current period rather than its running total. Three days into a month, "12 kWh vs 340 kWh last month" reads as a collapse in usage when nothing has changed. The projection is straight-line on elapsed days -- wrong for anything seasonal, but the honest reading of "at this rate" -- and the UI marks it with a leading ~. A 12-month bar strip gives the shape at a glance. A meter with nothing normalized yet returns an empty history rather than a flat line, which would look like a meter reading zero. Register span, reading count and lifetime total move into a collapsed panel. Still there when needed for an audit, no longer the first thing you see. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
125 lines
4.5 KiB
C#
125 lines
4.5 KiB
C#
using MeterVault.Core.Domain;
|
|
using MeterVault.Infrastructure.Costing;
|
|
using MeterVault.Infrastructure.Dashboard;
|
|
using MeterVault.Infrastructure.Options;
|
|
using MeterVault.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace MeterVault.Integration.Tests;
|
|
|
|
/// <summary>
|
|
/// The meter-detail headline numbers: month/year totals bucketed in the instance timezone, and the
|
|
/// generation-vs-consumption split that decides which of the two a meter reports.
|
|
/// </summary>
|
|
[Collection("Timescale")]
|
|
public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
|
|
{
|
|
[Fact]
|
|
public async Task Buckets_by_local_month_and_compares_with_the_previous_one()
|
|
{
|
|
await using var db = fx.CreateContext();
|
|
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
var thisMonth = new DateOnly(today.Year, today.Month, 1);
|
|
var lastMonth = thisMonth.AddMonths(-1);
|
|
|
|
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), 30);
|
|
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, lastMonth.AddDays(3), 100);
|
|
|
|
var view = await NewService().GetAsync(meterId);
|
|
|
|
Assert.NotNull(view);
|
|
Assert.Equal("Consumption", view!.Label);
|
|
Assert.Equal(30d, view.MonthToDate, 3);
|
|
Assert.Equal(100d, view.LastMonth, 3);
|
|
Assert.Equal(130d, view.YearToDate, 3);
|
|
|
|
// Projection scales the partial month up, so it must be at least what has already happened.
|
|
Assert.True(view.MonthProjected >= view.MonthToDate);
|
|
|
|
await CleanupAsync(db, meterId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_generation_counter_reports_generation_not_consumption()
|
|
{
|
|
// Regression: a PV meter showed "0 kWh consumption", which is true and useless.
|
|
await using var db = fx.CreateContext();
|
|
var meterId = await SetupAsync(db, MeterMode.GenerationCounter);
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
|
|
await AddConsumptionAsync(
|
|
db, meterId, ConsumptionKind.Generation, new DateOnly(today.Year, today.Month, 1).AddDays(1), 42);
|
|
|
|
var view = await NewService().GetAsync(meterId);
|
|
|
|
Assert.NotNull(view);
|
|
Assert.Equal("Generation", view!.Label);
|
|
Assert.Equal(42d, view.MonthToDate, 3);
|
|
|
|
await CleanupAsync(db, meterId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_meter_with_no_consumption_yields_an_empty_history_rather_than_a_flat_line()
|
|
{
|
|
await using var db = fx.CreateContext();
|
|
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
|
|
|
|
var view = await NewService().GetAsync(meterId);
|
|
|
|
Assert.NotNull(view);
|
|
Assert.False(view!.HasHistory);
|
|
Assert.Empty(view.Last12Months);
|
|
Assert.Null(view.MonthChange); // no previous month to divide by
|
|
|
|
await CleanupAsync(db, meterId);
|
|
}
|
|
|
|
private MeterPeriodService NewService()
|
|
{
|
|
var options = Microsoft.Extensions.Options.Options.Create(
|
|
new MeterVaultOptions { TimeZone = "Europe/Berlin", Currency = "EUR" });
|
|
return new MeterPeriodService(fx, new CostService(fx), options);
|
|
}
|
|
|
|
private static async Task<int> SetupAsync(MeterVaultDbContext db, MeterMode mode)
|
|
{
|
|
await DatabaseSeeder.SeedAsync(db);
|
|
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
|
|
|
|
var meter = new Meter
|
|
{
|
|
Name = $"period-{Guid.NewGuid():N}",
|
|
EnergyTypeId = type.Id,
|
|
Mode = mode,
|
|
Unit = "kWh",
|
|
};
|
|
db.Meters.Add(meter);
|
|
await db.SaveChangesAsync();
|
|
return meter.Id;
|
|
}
|
|
|
|
private static async Task AddConsumptionAsync(
|
|
MeterVaultDbContext db, int meterId, ConsumptionKind kind, DateOnly day, double amount)
|
|
{
|
|
db.Consumption.Add(new Consumption
|
|
{
|
|
MeterId = meterId,
|
|
// Midday local, so the row cannot drift into an adjacent month through the UTC offset.
|
|
Time = new DateTimeOffset(day.Year, day.Month, day.Day, 12, 0, 0, TimeSpan.Zero),
|
|
Kind = kind,
|
|
Amount = amount,
|
|
Quality = ReadingQuality.Measured,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
|
|
{
|
|
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
|
|
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
|
}
|
|
}
|