Files
MeterVault/tests/Integration.Tests/MeterPage/MeterDetailServiceTests.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
2026-09-20 12:44:32 +02:00

294 lines
14 KiB
C#

using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Options;
using MeterVault.Integration.Tests.Costing;
using static MeterVault.Integration.Tests.Costing.CostSandbox;
namespace MeterVault.Integration.Tests.MeterPage;
/// <summary>
/// The meter page's read model (brief §7.2, D-50): record tabs paged server-side, newest first, keyset-ordered on each
/// table's key and filtered by a half-open range; the manual-entry dialog's own context for the entered instant (the
/// neighbours the ingestion guard reads); markers inside a range; applicable tariffs; and a virtual meter's calculation.
/// </summary>
[Collection("Timescale")]
public sealed class MeterDetailServiceTests(TimescaleFixture fx)
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Readings_page_newest_first_by_keyset_and_filter_by_a_half_open_range()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await InsertReadingsAsync(meter, 250);
var service = Service();
var first = await service.GetReadingsAsync(meter, RecordRange.All);
var second = await service.GetReadingsAsync(meter, RecordRange.All, first.Next);
var third = await service.GetReadingsAsync(meter, RecordRange.All, second.Next);
Assert.Equal((100, 100, 50), (first.Rows.Count, second.Rows.Count, third.Rows.Count));
Assert.All(new[] { first, second, third }, page => Assert.Equal(250, page.Total));
Assert.NotNull(first.Next);
Assert.NotNull(second.Next);
Assert.Null(third.Next);
var all = first.Rows.Concat(second.Rows).Concat(third.Rows).Select(r => r.Time).ToList();
Assert.Equal(250, all.Distinct().Count());
Assert.Equal(all.OrderDescending(), all);
Assert.Equal(Start.AddHours(249), all[0]);
// Keyset, not offset: a reading arriving at the top does not shift the older pages.
await InsertReadingsAsync(meter, 1, fromHour: 1000);
var again = await service.GetReadingsAsync(meter, RecordRange.All, first.Next);
Assert.Equal(second.Rows, again.Rows);
Assert.Equal(251, again.Total);
// [from, to): the start is included, the end is not.
var range = new RecordRange(Start.AddHours(10), Start.AddHours(20));
var filtered = await service.GetReadingsAsync(meter, range);
Assert.Equal(10, filtered.Total);
Assert.Equal(Start.AddHours(19), filtered.Rows[0].Time);
Assert.Equal(Start.AddHours(10), filtered.Rows[^1].Time);
Assert.Null(filtered.Next);
}
[Fact]
public async Task Normalized_rows_break_ties_on_their_kind_across_a_page_boundary()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.DirectDelta, "kWh");
// One row at the newest instant, then 51 instants with both kinds: the 100th and 101st rows share an instant.
await using (var db = fx.CreateContext())
{
db.Consumption.Add(Row(meter, 51, ConsumptionKind.Consumption));
for (var i = 0; i <= 50; i++)
{
db.Consumption.Add(Row(meter, i, ConsumptionKind.Consumption));
db.Consumption.Add(Row(meter, i, ConsumptionKind.Generation));
}
await db.SaveChangesAsync();
}
var service = Service();
var first = await service.GetConsumptionAsync(meter, RecordRange.All);
var second = await service.GetConsumptionAsync(meter, RecordRange.All, first.Next);
Assert.Equal(103, first.Total);
Assert.Equal(100, first.Rows.Count);
Assert.Equal((Start.AddHours(1), ConsumptionKind.Generation), (first.Rows[^1].Time, first.Rows[^1].Kind));
Assert.Equal(3, second.Rows.Count);
Assert.Equal((Start.AddHours(1), ConsumptionKind.Consumption), (second.Rows[0].Time, second.Rows[0].Kind));
Assert.Null(second.Next);
Assert.Equal(103, first.Rows.Concat(second.Rows).Select(r => (r.Time, r.Kind)).Distinct().Count());
}
[Fact]
public async Task Events_page_on_their_instant_then_their_id()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
await using (var db = fx.CreateContext())
{
// All at one instant: only the id orders them.
for (var i = 0; i < 101; i++)
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Start, EventType = MeterEventType.Note, Notes = $"note {i}" });
}
await db.SaveChangesAsync();
}
var service = Service();
var first = await service.GetEventsAsync(meter, RecordRange.All);
var second = await service.GetEventsAsync(meter, RecordRange.All, first.Next);
Assert.Equal((100, 1, 101), (first.Rows.Count, second.Rows.Count, first.Total));
var ids = first.Rows.Concat(second.Rows).Select(e => e.Id).ToList();
Assert.Equal(101, ids.Distinct().Count());
Assert.Equal(ids.OrderDescending(), ids);
}
[Fact]
public async Task The_entry_context_judges_a_reading_by_the_neighbours_the_guard_reads()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
// Imported month rows: "July 2026" (400) and "August 2026" (500), stamped on the 1st (UTC, as the importer writes
// them), describing the month's end.
await using (var db = fx.CreateContext())
{
db.Readings.Add(new Reading { MeterId = meter, Time = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), Value = 400, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
db.Readings.Add(new Reading { MeterId = meter, Time = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), Value = 500, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
await db.SaveChangesAsync();
}
var service = Service();
var at = Midnight(2026, 8, 20);
var context = await service.GetReadingEntryContextAsync(meter, at);
// The latest reading by stamp is August's (500), but on the timeline a reading on 20 August comes after July's end
// and before August's: the dialog judges it against 400, exactly like the guard — the old page, holding only
// the latest row, would have called 450 a decrease.
Assert.NotNull(context);
Assert.Equal(500, context!.Latest!.Value);
Assert.Equal(400, context.Previous!.Value);
Assert.True(context.Monotonic);
Assert.False(context.BoundaryExplainsDecrease);
Assert.Null(context.AtTime);
await using (var db = fx.CreateContext())
{
var ingestion = new IngestionService(db, Normalization(db));
Assert.Equal(IngestionOutcome.RejectedDecrease, await ingestion.IngestByMeterAsync(meter, at, 350, renormalize: false, quality: ReadingQuality.Manual));
Assert.Equal(IngestionOutcome.Written, await ingestion.IngestByMeterAsync(meter, at, 450, renormalize: false, quality: ReadingQuality.Manual));
}
// The reading at that instant is now what a save would replace.
var replacing = await service.GetReadingEntryContextAsync(meter, at);
Assert.Equal(450, replacing!.AtTime!.Value);
Assert.False(replacing.AtTime.IsRegisterStart);
// A swap after the previous reading explains a lower value.
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2026, 9, 5), EventType = MeterEventType.MeterSwap, PrevValue = 520, NewValue = 0 });
await db.SaveChangesAsync();
}
var afterSwap = await service.GetReadingEntryContextAsync(meter, Midnight(2026, 9, 10));
Assert.True(afterSwap!.BoundaryExplainsDecrease);
Assert.Null(await service.GetReadingEntryContextAsync(int.MaxValue, at));
}
[Fact]
public async Task The_identity_read_carries_the_register_span_and_the_normalized_unit()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", installedAt: D(2026, 1, 1));
await box.ReadingsAsync(meter, (Midnight(2026, 1, 1), 10), (Midnight(2026, 2, 1), 110));
var service = Service();
var detail = await service.GetAsync(meter);
Assert.NotNull(detail);
Assert.True(detail!.HasReadings);
Assert.False(detail.HasEvents);
Assert.Equal((10d, 110d), (detail.FirstReading!.Value, detail.LastReading!.Value));
Assert.Equal(QuantityKind.Generation, detail.Kind);
Assert.Equal("kWh", detail.NormalizedUnit);
Assert.Equal(D(2026, 1, 1), detail.InstalledAt);
Assert.Null(await service.GetAsync(int.MaxValue));
}
[Fact]
public async Task Markers_hold_the_events_and_tariff_changes_inside_the_range_only()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
var other = await box.TypeAsync();
await box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
await box.TypePriceAsync(type, 0.31, D(2025, 5, 1));
await box.TypePriceAsync(type, 0.32, D(2025, 7, 1));
await box.MeterPriceAsync(meter, 0.25, D(2025, 3, 1));
await box.TypePriceAsync(other, 0.99, D(2025, 3, 1));
await using (var db = fx.CreateContext())
{
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 2, 10), EventType = MeterEventType.Note, Notes = "before" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 4, 10), EventType = MeterEventType.Note, Notes = "inside" });
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "later" });
await db.SaveChangesAsync();
}
var service = Service();
var markers = await service.GetMarkersAsync(
meter, new RecordRange(Midnight(2025, 3, 1), Midnight(2025, 7, 1)), D(2025, 3, 1), D(2025, 6, 30));
// Both lists newest first (A-42): the page draws them as one dated list.
Assert.Equal(["later", "inside"], markers.Events.Select(e => e.Notes));
Assert.False(markers.MoreEvents);
Assert.Equal([D(2025, 5, 1), D(2025, 3, 1)], markers.TariffChanges.Select(t => t.ValidFrom));
Assert.Equal((TariffScope.Meter, 0.25), (markers.TariffChanges[1].Scope, markers.TariffChanges[1].Value));
// The tariff list: the meter's own and its type's (and any global), never another type's — newest first
// inside each scope, so the price that applies now is at the top (A-42).
var tariffs = await service.GetTariffsAsync(meter);
Assert.Contains(tariffs, t => t.Scope == TariffScope.Meter && t.ScopeId == meter);
Assert.Equal(3, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
Assert.DoesNotContain(tariffs, t => t.Scope == TariffScope.EnergyType && t.ScopeId == other);
Assert.Equal(
[D(2025, 7, 1), D(2025, 5, 1), D(2025, 1, 1)],
tariffs.Where(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type).Select(t => t.ValidFrom));
}
[Fact]
public async Task A_virtual_meters_calculation_names_its_sources_and_its_problems()
{
await using var box = new CostSandbox(fx);
var type = await box.TypeAsync();
var a = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", name: "PV Ost");
var b = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", name: "PV West");
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
var service = Service();
var calculation = await service.GetCalculationAsync(sum);
Assert.NotNull(calculation);
Assert.Equal(VirtualMeterStatus.Valid, calculation!.Status);
Assert.Equal($"m{a} + m{b}", calculation.Expression);
Assert.Equal((QuantityKind.Generation, "kWh"), (calculation.Kind, calculation.Unit));
Assert.Equal(["PV Ost", "PV West"], calculation.Sources.Select(s => s.Name));
Assert.All(calculation.Sources, s => Assert.True(s.Exists));
Assert.Equal("PV West", calculation.NameOf(b));
Assert.Empty(calculation.Problems);
var invalid = await service.GetCalculationAsync(broken);
Assert.Equal(VirtualMeterStatus.Invalid, invalid!.Status);
var problem = Assert.Single(invalid.Problems);
Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind);
Assert.Equal([int.MaxValue], problem.MeterIds);
Assert.Contains(invalid.Sources, s => s.MeterId == int.MaxValue && !s.Exists);
// A physical meter has no calculation.
Assert.Null(await service.GetCalculationAsync(a));
}
private MeterDetailService Service() =>
new(fx, Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }));
private static Consumption Row(int meter, int hour, ConsumptionKind kind) => new()
{
MeterId = meter,
Time = Start.AddHours(hour),
Amount = 1,
Kind = kind,
Quality = ReadingQuality.Measured,
};
private async Task InsertReadingsAsync(int meter, int count, int fromHour = 0)
{
await using var db = fx.CreateContext();
for (var i = 0; i < count; i++)
{
db.Readings.Add(new Reading { MeterId = meter, Time = Start.AddHours(fromHour + i), Value = fromHour + i, Quality = ReadingQuality.Measured });
}
await db.SaveChangesAsync();
}
}