Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.
Meter events from the UI
Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.
A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.
Navigation
The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.
A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.
Months
A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.
Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.
ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.
Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.
Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.
What an operator sees once
Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.
Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
428 lines
23 KiB
C#
428 lines
23 KiB
C#
using MeterVault.Core.Domain;
|
||
using MeterVault.Core.Normalization;
|
||
using MeterVault.Infrastructure.Ingestion;
|
||
using MeterVault.Infrastructure.Normalization;
|
||
using MeterVault.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace MeterVault.Integration.Tests.Ingestion;
|
||
|
||
/// <summary>
|
||
/// Recording meter events from the meter page: what a swap, reset, tank level, delivery or note
|
||
/// actually persists, that the derived consumption follows in the same step, and that a mistake can
|
||
/// be taken back without leaving the series worse than before.
|
||
/// </summary>
|
||
[Collection("Timescale")]
|
||
public sealed class MeterEventServiceTests(TimescaleFixture fx)
|
||
{
|
||
private static readonly DateTimeOffset Yesterday = new(2026, 9, 16, 18, 0, 0, TimeSpan.Zero);
|
||
private static readonly DateTimeOffset SwapAt = new(2026, 9, 17, 9, 30, 0, TimeSpan.Zero);
|
||
|
||
[Fact]
|
||
public async Task A_swap_recorded_today_books_the_old_tail_and_lets_the_new_register_count_on()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday.AddDays(-30), 848, quality: ReadingQuality.Manual);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
|
||
var result = await NewService(db).RecordAsync(meterId,
|
||
new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 2 });
|
||
|
||
Assert.True(result.Succeeded, result.Problem.ToString());
|
||
|
||
// The event carries both registers; the new register's start is a real, flagged manual reading.
|
||
var swap = await db.MeterEvents.AsNoTracking().SingleAsync(e => e.MeterId == meterId);
|
||
Assert.Equal((873d, 2d, "m3"), (swap.PrevValue!.Value, swap.NewValue!.Value, swap.Unit));
|
||
var start = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == SwapAt);
|
||
Assert.Equal(2d, start.Value, 9);
|
||
Assert.Equal(ReadingQuality.Manual, start.Quality);
|
||
Assert.True(start.Flags.HasFlag(ReadingFlags.MeterSwap));
|
||
|
||
// The old meter's last 12 m³ land at the swap — not a −859 anomaly, not an 871 spike.
|
||
var atSwap = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt);
|
||
Assert.Equal(12d, atSwap.Amount, 9);
|
||
|
||
// A reading of the new register is accepted and counts from its start value.
|
||
var next = await ingestion.IngestByMeterAsync(meterId, SwapAt.AddHours(8), 2.4, quality: ReadingQuality.Manual);
|
||
Assert.Equal(IngestionOutcome.Written, next);
|
||
var afterSwap = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt.AddHours(8));
|
||
Assert.Equal(0.4, afterSwap.Amount, 9);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_new_reading_typed_at_the_swap_instant_replaces_the_start_value_and_still_reconciles()
|
||
{
|
||
// The reading dialog's "meter swapped?" hand-off: the user records the swap at the time they
|
||
// were typing a reading, then saves that reading at the same instant.
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
|
||
await NewService(db).RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 });
|
||
var outcome = await ingestion.IngestByMeterAsync(meterId, SwapAt, 0.3, quality: ReadingQuality.Manual);
|
||
|
||
Assert.Equal(IngestionOutcome.Updated, outcome);
|
||
var atSwap = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt);
|
||
Assert.Equal(12.3, atSwap.Amount, 9); // 873 − 861, plus 0.3 on the new register
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_swap_is_refused_when_its_numbers_or_its_instant_cannot_be_right()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
var service = NewService(db);
|
||
|
||
// The old register cannot end below a reading already taken from it.
|
||
var below = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 850, NewValue = 0 });
|
||
Assert.Equal(MeterEventProblem.OldRegisterBelowPreviousReading, below.Problem);
|
||
|
||
// A reading already sits at that instant: the start reading would silently overwrite it.
|
||
var sameTime = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, Yesterday) { PrevValue = 861, NewValue = 0 });
|
||
Assert.Equal(MeterEventProblem.ReadingAtSameTime, sameTime.Problem);
|
||
|
||
// A tank event means nothing on a register.
|
||
var delivery = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Delivery, SwapAt) { Amount = 100 });
|
||
Assert.Equal(MeterEventProblem.NotRecordableForMode, delivery.Problem);
|
||
|
||
Assert.False(await db.MeterEvents.AnyAsync(e => e.MeterId == meterId));
|
||
|
||
// A second swap between the same two readings would never be applied.
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 })).Succeeded);
|
||
var context = await service.GetContextAsync(meterId, SwapAt.AddMinutes(-10));
|
||
Assert.True(context!.BoundaryInWindow);
|
||
var duplicate = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt.AddMinutes(-10)) { PrevValue = 873, NewValue = 0 });
|
||
Assert.Equal(MeterEventProblem.BoundaryAlreadyRecorded, duplicate.Problem);
|
||
|
||
// ...but a genuine later swap, after the new register has been read, is fine.
|
||
await ingestion.IngestByMeterAsync(meterId, SwapAt.AddDays(10), 5, quality: ReadingQuality.Manual);
|
||
var later = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt.AddDays(20)) { PrevValue = 9, NewValue = 0 });
|
||
Assert.True(later.Succeeded, later.Problem.ToString());
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_reset_with_the_last_register_value_keeps_the_stretch_before_it()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "kWh");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 99_990, quality: ReadingQuality.Manual);
|
||
|
||
var result = await NewService(db).RecordAsync(meterId,
|
||
new MeterEventDraft(MeterEventType.CounterReset, SwapAt) { PrevValue = 99_999, NewValue = 0 });
|
||
|
||
Assert.True(result.Succeeded, result.Problem.ToString());
|
||
var start = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == SwapAt);
|
||
Assert.True(start.Flags.HasFlag(ReadingFlags.CounterReset));
|
||
var atReset = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId && c.Time == SwapAt);
|
||
Assert.Equal(9d, atReset.Amount, 9);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Tank_levels_and_deliveries_drive_the_tank_and_centimetres_need_a_calibration()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.ConsumableBalance, "L");
|
||
var service = NewService(db);
|
||
|
||
// No tank yet: a dipstick reading in cm cannot be turned into litres.
|
||
var uncalibrated = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday) { Amount = 50, Unit = "cm" });
|
||
Assert.Equal(MeterEventProblem.LevelNeedsCalibration, uncalibrated.Problem);
|
||
|
||
db.Tanks.Add(new Tank
|
||
{
|
||
MeterId = meterId,
|
||
Capacity = 7000,
|
||
Unit = "L",
|
||
Calibration = MeterConfigFactory.SerializeCalibration(new CalibrationCurve(7000d / 150d)),
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday.AddDays(-60)) { Amount = 50, Unit = "cm" })).Succeeded);
|
||
Assert.Equal(MeterEventProblem.AmountOutOfRange,
|
||
(await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Delivery, Yesterday.AddDays(-30)) { Amount = 0 })).Problem);
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Delivery, Yesterday.AddDays(-30)) { Amount = 2000 })).Succeeded);
|
||
|
||
var context = await service.GetContextAsync(meterId, Yesterday);
|
||
Assert.Equal(2333.33, context!.LastLevel!.Volume, 1);
|
||
Assert.Equal(2000d, context.DeliveredSinceLastLevel, 9);
|
||
Assert.Equal(600d, context.UsedSinceLastLevel(context.ToVolume(80, centimetres: true))!.Value, 1);
|
||
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday) { Amount = 80, Unit = "cm" })).Succeeded);
|
||
|
||
// 2333.3 L + 2000 L delivered − 3733.3 L now = 600 L drawn, booked at the new level.
|
||
var drawn = await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId);
|
||
Assert.Equal(600d, drawn.Amount, 1);
|
||
Assert.Equal(Yesterday, drawn.Time);
|
||
var delivery = await db.MeterEvents.AsNoTracking().SingleAsync(e => e.MeterId == meterId && e.EventType == MeterEventType.Delivery);
|
||
Assert.Equal("L", delivery.Unit);
|
||
|
||
await db.Tanks.Where(t => t.MeterId == meterId).ExecuteDeleteAsync();
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_note_needs_text_and_is_offered_on_every_mode()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.InstantRate, "W");
|
||
var service = NewService(db);
|
||
|
||
Assert.Equal(MeterEventProblem.NoteRequired,
|
||
(await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Note, SwapAt) { Notes = " " })).Problem);
|
||
Assert.Equal(MeterEventProblem.NotRecordableForMode,
|
||
(await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { NewValue = 0 })).Problem);
|
||
|
||
var note = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.Note, SwapAt) { Notes = " Sensor moved to the new fuse box " });
|
||
|
||
Assert.True(note.Succeeded);
|
||
Assert.Equal("Sensor moved to the new fuse box", (await db.MeterEvents.AsNoTracking().SingleAsync(e => e.Id == note.EventId)).Notes);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Deleting_a_swap_takes_its_start_reading_along_and_restores_the_series()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
var service = NewService(db);
|
||
var before = await ConsumptionAsync(db, meterId);
|
||
|
||
var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 875, NewValue = 0 });
|
||
var deleted = await service.DeleteEventAsync(meterId, swap.EventId!.Value);
|
||
|
||
Assert.True(deleted.Succeeded);
|
||
Assert.False(await db.MeterEvents.AnyAsync(e => e.MeterId == meterId));
|
||
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == SwapAt));
|
||
Assert.Equal(before, await ConsumptionAsync(db, meterId));
|
||
|
||
// Re-recording it correctly works straight away — nothing was left behind to trip over.
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 })).Succeeded);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Deleting_a_swap_keeps_a_real_reading_later_typed_at_the_same_instant()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
var service = NewService(db);
|
||
|
||
var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 });
|
||
await ingestion.IngestByMeterAsync(meterId, SwapAt, 0.3, quality: ReadingQuality.Manual);
|
||
await service.DeleteEventAsync(meterId, swap.EventId!.Value);
|
||
|
||
Assert.True(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == SwapAt));
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Imported_events_and_non_manual_readings_are_not_deletable_here()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var batch = new ImportBatch { SourceName = "test.csv", CreatedAt = DateTimeOffset.UtcNow };
|
||
db.ImportBatches.Add(batch);
|
||
await db.SaveChangesAsync();
|
||
var imported = new MeterEvent { MeterId = meterId, Time = Yesterday, EventType = MeterEventType.MeterSwap, PrevValue = 1, NewValue = 0, ImportBatchId = batch.Id };
|
||
db.MeterEvents.Add(imported);
|
||
db.Readings.Add(new Reading { MeterId = meterId, Time = SwapAt, Value = 5, Quality = ReadingQuality.Measured });
|
||
await db.SaveChangesAsync();
|
||
var service = NewService(db);
|
||
|
||
Assert.Equal(MeterEventProblem.Imported, (await service.DeleteEventAsync(meterId, imported.Id)).Problem);
|
||
Assert.Equal(MeterEventProblem.NotManual, (await service.DeleteManualReadingAsync(meterId, SwapAt)).Problem);
|
||
Assert.Equal(MeterEventProblem.NotFound, (await service.DeleteEventAsync(meterId + 100_000, imported.Id)).Problem);
|
||
|
||
await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
|
||
await db.ImportBatches.Where(b => b.Id == batch.Id).ExecuteDeleteAsync();
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_mistyped_manual_reading_can_be_deleted_and_the_next_one_is_accepted_again()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 1873.4, quality: ReadingQuality.Manual);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(1), 18734, quality: ReadingQuality.Manual); // typo
|
||
Assert.Equal(IngestionOutcome.RejectedDecrease,
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(2), 1874, quality: ReadingQuality.Manual));
|
||
|
||
var deleted = await NewService(db).DeleteManualReadingAsync(meterId, Yesterday.AddHours(1));
|
||
|
||
Assert.True(deleted.Succeeded);
|
||
Assert.DoesNotContain(await ConsumptionAsync(db, meterId), c => c > 10_000);
|
||
Assert.Equal(IngestionOutcome.Written,
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(2), 1874, quality: ReadingQuality.Manual));
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task The_context_warns_about_readings_after_a_backdated_event_and_live_sources()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
await ingestion.IngestByMeterAsync(meterId, SwapAt.AddHours(1), 862, quality: ReadingQuality.Manual);
|
||
await ingestion.IngestByMeterAsync(meterId, SwapAt.AddHours(2), 863, quality: ReadingQuality.Manual);
|
||
db.MeterSources.Add(new MeterSource { MeterId = meterId, SourceType = SourceType.HomeAssistant, IsEnabled = true });
|
||
db.MeterSources.Add(new MeterSource { MeterId = meterId, SourceType = SourceType.Manual, IsEnabled = true });
|
||
await db.SaveChangesAsync();
|
||
|
||
var context = await NewService(db).GetContextAsync(meterId, SwapAt);
|
||
|
||
Assert.NotNull(context);
|
||
Assert.Equal(new ReadingPoint(Yesterday, 861), context.Previous);
|
||
Assert.Equal(SwapAt.AddHours(1), context.Next!.Time);
|
||
Assert.Equal(2, context.ReadingsAfter);
|
||
Assert.False(context.ReadingAtTime);
|
||
Assert.Equal(1, context.LiveSources); // manual sources do not keep feeding the old register
|
||
Assert.Equal(12d, context.Tail(873)!.Value, 9);
|
||
|
||
await db.MeterSources.Where(s => s.MeterId == meterId).ExecuteDeleteAsync();
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_second_tank_level_at_the_same_instant_is_refused_instead_of_crashing_the_recompute()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.ConsumableBalance, "L");
|
||
var service = NewService(db);
|
||
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, Yesterday.AddDays(-30)) { Amount = 3000 })).Succeeded);
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, SwapAt) { Amount = 2500 })).Succeeded);
|
||
|
||
// Re-entered within the same minute to fix a typo: two levels at one instant cannot both book.
|
||
var again = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.TankLevel, SwapAt) { Amount = 2400 });
|
||
|
||
Assert.Equal(MeterEventProblem.LevelAtSameTime, again.Problem);
|
||
Assert.Equal(500d, (await db.Consumption.AsNoTracking().SingleAsync(c => c.MeterId == meterId)).Amount, 9);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_boundary_cannot_be_removed_while_a_later_swap_was_measured_against_it()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 861, quality: ReadingQuality.Manual);
|
||
var service = NewService(db);
|
||
|
||
// A reset, then — a minute later, with no reading in between — a swap whose old register is
|
||
// measured from the reset's start reading of 0.
|
||
var reset = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.CounterReset, SwapAt) { PrevValue = 873, NewValue = 0 });
|
||
var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt.AddMinutes(1)) { PrevValue = 0, NewValue = 0 });
|
||
Assert.True(reset.Succeeded && swap.Succeeded);
|
||
var before = await ConsumptionAsync(db, meterId);
|
||
|
||
// Removing the reset (or just its start reading) would re-measure that swap from 861: −861 m³.
|
||
Assert.Equal(MeterEventProblem.LaterBoundaryDependsOnIt, (await service.DeleteEventAsync(meterId, reset.EventId!.Value)).Problem);
|
||
Assert.Equal(MeterEventProblem.LaterBoundaryDependsOnIt, (await service.DeleteManualReadingAsync(meterId, SwapAt)).Problem);
|
||
Assert.Equal(before, await ConsumptionAsync(db, meterId));
|
||
Assert.DoesNotContain(before, c => c < 0);
|
||
|
||
// Later first, then earlier: both go, and nothing negative is ever booked on the way.
|
||
Assert.True((await service.DeleteEventAsync(meterId, swap.EventId!.Value)).Succeeded);
|
||
Assert.True((await service.DeleteEventAsync(meterId, reset.EventId!.Value)).Succeeded);
|
||
Assert.DoesNotContain(await ConsumptionAsync(db, meterId), c => c < 0);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_typo_cannot_be_deleted_out_from_under_a_swap_recorded_after_it()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3");
|
||
var ingestion = NewIngestion(db);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday, 1873, quality: ReadingQuality.Manual);
|
||
await ingestion.IngestByMeterAsync(meterId, Yesterday.AddHours(1), 18734, quality: ReadingQuality.Manual); // typo
|
||
var service = NewService(db);
|
||
var swap = await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 18734, NewValue = 0 });
|
||
Assert.True(swap.Succeeded);
|
||
|
||
Assert.Equal(MeterEventProblem.LaterBoundaryDependsOnIt,
|
||
(await service.DeleteManualReadingAsync(meterId, Yesterday.AddHours(1))).Problem);
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Without_an_earlier_reading_the_old_register_is_measured_from_the_baseline()
|
||
{
|
||
await using var db = fx.CreateContext();
|
||
var meterId = await CreateMeterAsync(db, MeterMode.CumulativeCounter, "m3", initialBaseline: 900);
|
||
var service = NewService(db);
|
||
|
||
var context = await service.GetContextAsync(meterId, SwapAt);
|
||
Assert.Null(context!.Previous);
|
||
Assert.Equal(50d, context.Tail(950)!.Value, 9);
|
||
|
||
// Below the baseline would book a negative tail, exactly as below a reading would.
|
||
Assert.Equal(MeterEventProblem.OldRegisterBelowPreviousReading,
|
||
(await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 873, NewValue = 0 })).Problem);
|
||
|
||
Assert.True((await service.RecordAsync(meterId, new MeterEventDraft(MeterEventType.MeterSwap, SwapAt) { PrevValue = 950, NewValue = 0 })).Succeeded);
|
||
Assert.Equal([50d], await ConsumptionAsync(db, meterId));
|
||
|
||
await CleanupAsync(db, meterId);
|
||
}
|
||
|
||
private static MeterEventService NewService(MeterVaultDbContext db)
|
||
{
|
||
var normalization = new NormalizationService(db, NormalizationEngine.CreateDefault());
|
||
return new MeterEventService(db, new IngestionService(db, normalization), normalization);
|
||
}
|
||
|
||
private static IngestionService NewIngestion(MeterVaultDbContext db) =>
|
||
new(db, new NormalizationService(db, NormalizationEngine.CreateDefault()));
|
||
|
||
private static async Task<List<double>> ConsumptionAsync(MeterVaultDbContext db, int meterId) =>
|
||
await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).OrderBy(c => c.Time).Select(c => c.Amount).ToListAsync();
|
||
|
||
private static async Task<int> CreateMeterAsync(MeterVaultDbContext db, MeterMode mode, string unit, double initialBaseline = 0)
|
||
{
|
||
await DatabaseSeeder.SeedAsync(db);
|
||
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "water");
|
||
var meter = new Meter { Name = $"events-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = mode, Unit = unit, InitialBaseline = initialBaseline };
|
||
db.Meters.Add(meter);
|
||
await db.SaveChangesAsync();
|
||
return meter.Id;
|
||
}
|
||
|
||
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
|
||
{
|
||
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
|
||
await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
|
||
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
|
||
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||
}
|
||
}
|