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; /// /// 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. /// [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(); } }