Meters: record events from the UI, and book consumption in the months it accrued in

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.
This commit is contained in:
Florian Schmidt
2026-09-17 21:09:17 +02:00
parent 0319e5527f
commit aacdc28d70
76 changed files with 6872 additions and 828 deletions
@@ -72,6 +72,54 @@ public sealed class CumulativeCounterNormalizerTests
Assert.Equal([10d, 50d, 30d, 50d], result.Select(c => c.Amount));
}
[Fact]
public void Counter_reset_with_a_final_register_books_the_stretch_before_the_reset()
{
// The register climbed 150 → 170 after the February reading, then reset to 0 and reached 30
// by March. Knowing the 170 turns the reset from "lose 20" into the swap formula: 20 + 30.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InitialBaseline = 90 },
Readings =
[
Reading(1, Month(2023, 1), 100),
Reading(1, Month(2023, 2), 150),
Reading(1, Month(2023, 3), 30),
Reading(1, Month(2023, 4), 80),
],
Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: 170)],
};
var result = _engine.Normalize(ctx);
Assert.Equal([10d, 50d, 50d, 50d], result.Select(c => c.Amount));
}
[Fact]
public void Swap_recorded_with_the_new_meters_start_reading_at_the_same_instant_books_the_tail_there()
{
// What the meter page records for "the meter was swapped today": the swap event and a reading of
// the new register's start value, both at the swap instant. The old meter's tail (873 861)
// lands at the swap, and the next reading counts from the new register's start.
var swapAt = Month(2023, 3).AddDays(16).AddHours(9);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 10, Mode = MeterMode.CumulativeCounter, Unit = "m3", InitialBaseline = 848 },
Readings =
[
DayReading(10, Month(2023, 3), 861),
Reading(10, swapAt, 2),
Reading(10, swapAt.AddDays(1), 2.5),
],
Events = [Swap(10, swapAt, prevValue: 873, newValue: 2)],
};
var result = _engine.Normalize(ctx);
Assert.Equal([13d, 12d, 0.5d], result.Select(c => c.Amount));
Assert.Equal(swapAt, result[1].Time);
}
[Fact]
public void Unexplained_decrease_yields_zero_and_marks_quality()
{
+413 -102
View File
@@ -5,125 +5,406 @@ using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests;
/// <summary>
/// A counter delta is booked at the reading that closes it. That is correct at the reporting cadence
/// and wrong after a long outage, so a gap containing two or more whole months is apportioned.
/// The boundary between those two behaviours is what these pin down: a normal monthly series must
/// come out byte-for-byte unchanged, because it is what reconciles against the reference spreadsheet.
/// Consumption between two readings belongs to the months it accrued in. What these pin down: an
/// interval crossing a local month boundary is divided by elapsed time; an imported monthly table
/// still produces exactly one unchanged row per row, because that is what reconciles against the
/// reference spreadsheet (SDD §13).
/// </summary>
public sealed class GapAttributionTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static DateTimeOffset BerlinTime(int year, int month, int day, int hour = 0, int minute = 0) =>
new(new DateTime(year, month, day, hour, minute, 0), Berlin.GetUtcOffset(new DateTime(year, month, day, hour, minute, 0)));
private static Reading Manual(DateTimeOffset time, double value) =>
new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Manual };
private static string LocalMonth(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Berlin).ToString("yyyy-MM");
[Fact]
public void A_monthly_cadence_is_never_split()
public void Readings_from_1_August_to_16_September_are_divided_between_the_two_months()
{
// One whole month per interval — the reference-data shape. Splitting here would move energy
// between months and break reconciliation (SDD §13).
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2)));
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(-1)));
// The reported case: nothing read in between, so September used to carry all 46 days.
var august1 = BerlinTime(2026, 8, 1, 9, 0);
var september16 = BerlinTime(2026, 9, 16, 18, 0);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(august1, 700), Manual(september16, 746)],
TimeZone = Berlin,
};
// A reading that lands hours late must not tip the rule and hand January a sliver.
Assert.False(GapAttribution.ShouldSplit(Month(2023, 12), Month(2024, 1).AddHours(6)));
var result = _engine.Normalize(ctx).ToList();
var interval = result.Skip(1).ToList();
// Nor should a six-week interval, which still contains only one whole month.
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(14)));
Assert.Equal(2, interval.Count);
Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time)));
Assert.Equal(46, interval.Sum(c => c.Amount), 9);
var total = september16 - august1;
var inAugust = BerlinTime(2026, 9, 1) - august1;
Assert.Equal(46 * (inAugust / total), interval[0].Amount, 9);
Assert.All(interval, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
// September's share still sits on the reading that closed the interval.
Assert.Equal(september16, interval[1].Time);
}
[Fact]
public void Sub_month_intervals_are_never_split()
public void An_interval_inside_one_month_is_one_row_at_its_reading_with_its_own_quality()
{
Assert.False(GapAttribution.ShouldSplit(Month(2023, 5), Month(2023, 5).AddHours(1)));
Assert.False(GapAttribution.ShouldSplit(Month(2023, 5).AddDays(10), Month(2023, 5).AddDays(20)));
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(BerlinTime(2026, 9, 1, 8), 700), Manual(BerlinTime(2026, 9, 16, 18), 710)],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(BerlinTime(2026, 9, 16, 18), result[1].Time);
Assert.Equal(ReadingQuality.Manual, result[1].Quality);
}
[Fact]
public void A_skipped_month_is_split()
public void Months_are_local_so_a_reading_just_after_local_midnight_does_not_take_the_month_with_it()
{
Assert.True(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 3)));
Assert.True(GapAttribution.ShouldSplit(Month(2026, 5), new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero)));
// 1 September 00:30 in Berlin is still 31 August in UTC. Split at UTC boundaries this interval
// would be one row stamped in local September carrying all of August.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1, 0, 30), 731)],
TimeZone = Berlin,
};
var interval = _engine.Normalize(ctx).Skip(1).ToList();
Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time)));
Assert.True(interval[0].Amount > 30.9, $"August got only {interval[0].Amount}");
}
[Fact]
public void Splitting_preserves_the_total_and_keeps_the_closing_timestamp()
public void A_reading_exactly_at_local_midnight_on_the_first_books_wholly_to_the_month_before()
{
var start = Month(2026, 5);
var end = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1), 731)],
TimeZone = Berlin,
};
var segments = GapAttribution.Split(start, end, 714.5);
var result = _engine.Normalize(ctx).ToList();
// May, June, July.
Assert.Equal(3, segments.Count);
Assert.Equal(714.5, segments.Sum(s => s.Amount), 6);
Assert.Equal(end, segments[^1].Time);
Assert.Equal(Month(2026, 6), segments[0].Time);
Assert.Equal(Month(2026, 7), segments[1].Time);
Assert.Equal(2, result.Count);
Assert.Equal("2026-08", LocalMonth(result[1].Time));
Assert.Equal(31, result[1].Amount, 9);
Assert.Equal(ReadingQuality.Manual, result[1].Quality);
}
[Fact]
public void Each_month_gets_a_share_proportional_to_the_time_it_covers()
public void An_ordinary_imported_monthly_series_produces_one_unchanged_row_per_reading()
{
// Exactly two whole months: an even split, to the cent.
var segments = GapAttribution.Split(Month(2023, 1), Month(2023, 3), 620);
// The reference-data shape: rows stamped 00:00 UTC on the 1st. It must not gain rows, move
// them, or lose its quality — in UTC or in the instance timezone.
foreach (var zone in new[] { TimeZoneInfo.Utc, Berlin })
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2022, 9), 0),
Reading(1, Month(2022, 10), 411),
Reading(1, Month(2022, 11), 1153),
Reading(1, Month(2022, 12), 1968),
],
TimeZone = zone,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(4, result.Count);
Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time));
}
}
[Fact]
public void A_month_label_is_an_imported_row_the_importer_flagged_as_a_month()
{
Assert.True(GapAttribution.IsMonthLabel(Reading(1, Month(2026, 5), 1)));
Assert.False(GapAttribution.IsMonthLabel(DayReading(1, Month(2026, 5), 1)));
// Correcting a typo in an imported month row by hand does not turn it into a reading on the 1st.
Assert.True(GapAttribution.IsMonthLabel(new Reading
{
Time = Month(2026, 5), Quality = ReadingQuality.Manual, Flags = ReadingFlags.MonthLabel,
}));
// "Mai 2026" is the register at the end of May.
Assert.Equal(BerlinTime(2026, 6, 1), GapAttribution.EffectiveTime(Reading(1, Month(2026, 5), 1), Berlin));
}
[Fact]
public void A_day_dated_import_on_the_first_is_an_instant_not_a_month()
{
// A meter log imported with "15.07.2026" and "01.08.2026", then read by hand on 16 September. The
// row on the 1st is stamped at the same midnight as a month label but means that day.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings =
[
DayReading(1, new DateTimeOffset(2026, 7, 15, 0, 0, 0, TimeSpan.Zero), 600),
DayReading(1, Month(2026, 8), 700),
Manual(BerlinTime(2026, 9, 16, 18), 746),
],
TimeZone = Berlin,
};
var byMonth = _engine.Normalize(ctx).Skip(1)
.GroupBy(c => LocalMonth(c.Time))
.ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
// 15 July to 1 August 00:00 UTC ends at 02:00 local on the 1st: two hours of the 100 in August, not 65.
Assert.True(byMonth["2026-07"] > 99.4, $"July got only {byMonth["2026-07"]}");
// ...and the six weeks after it are shared between August and September.
Assert.True(byMonth["2026-08"] is > 30 and < 31, $"August got {byMonth["2026-08"]}");
Assert.True(byMonth["2026-09"] is > 15 and < 16, $"September got {byMonth["2026-09"]}");
Assert.Equal(146, byMonth.Values.Sum(), 6);
}
[Fact]
public void A_monthly_table_imported_after_live_readings_does_not_count_its_month_twice()
{
// Read by hand on 1 August and 16 September; later the sheet rows "Juli" and "August" are imported.
// "August 2026" = 731 is the register at the end of August, so it belongs after the reading taken
// on 1 August even though it is stamped at midnight that day.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings =
[
Reading(1, Month(2026, 7), 699),
Reading(1, Month(2026, 8), 731),
Manual(BerlinTime(2026, 8, 1, 9), 700),
Manual(BerlinTime(2026, 9, 16, 18), 746),
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
var byMonth = result.GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
Assert.Equal(746, result.Sum(c => c.Amount), 6);
Assert.Equal(32, byMonth["2026-08"], 6); // 1 on the morning of the 1st, 31 through the month
Assert.Equal(15, byMonth["2026-09"], 6);
Assert.DoesNotContain(result, c => c.Amount < 0);
}
[Fact]
public void Behind_utc_imported_months_stay_in_their_own_local_month()
{
// In New York, 00:00 UTC on the 1st is still the evening before. The water sheet rows, including
// the swap in March, must each land in the month they name: none doubled, none empty.
var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings =
[
Reading(1, Month(2022, 12), 834),
Reading(1, Month(2023, 1), 848),
Reading(1, Month(2023, 2), 861),
Reading(1, Month(2023, 3), 2),
Reading(1, Month(2023, 4), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
TimeZone = newYork,
};
var result = _engine.Normalize(ctx).ToList();
string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM");
Assert.Equal(
["2022-12", "2023-01", "2023-02", "2023-03", "2023-04"],
result.Select(c => NewYorkMonth(c.Time)));
Assert.Equal([834, 14, 13, 12, 13], result.Select(c => c.Amount).ToArray());
}
[Fact]
public void Behind_utc_a_live_reading_next_to_imported_months_never_duplicates_a_stored_row()
{
// Imported "Juni" and "Juli", and HA polled at exactly local midnight on 1 July, the instant the
// July row is stamped at. Two rows on one key used to fail the whole recompute; they now add up.
var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var julyMidnight = new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero); // 00:00 EDT
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2026, 6), 1000),
Reading(1, Month(2026, 7), 1300),
new Reading { MeterId = 1, Time = julyMidnight, Value = 1005, Quality = ReadingQuality.Measured },
new Reading { MeterId = 1, Time = new DateTimeOffset(2026, 8, 10, 16, 0, 0, TimeSpan.Zero), Value = 1420, Quality = ReadingQuality.Measured },
],
TimeZone = newYork,
};
var result = _engine.Normalize(ctx).ToList();
var july = result.Where(c => TimeZoneInfo.ConvertTime(c.Time, newYork).ToString("yyyy-MM") == "2026-07").ToList();
Assert.Equal(result.Count, result.Select(c => (c.Time, c.Kind)).Distinct().Count());
Assert.Equal(1420, result.Sum(c => c.Amount), 6);
Assert.Equal(300, july.Sum(c => c.Amount), 6);
}
[Fact]
public void A_swap_detected_in_a_monthly_table_applies_to_the_first_live_reading_of_that_month()
{
// HA already read the new water meter on 10 and 25 March; later the sheet is imported and the
// importer detects the swap at the "Maerz" row. The old tail must not be measured from 1.5.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings =
[
Reading(1, Month(2023, 1), 848),
Reading(1, Month(2023, 2), 861),
Manual(BerlinTime(2023, 3, 10, 12), 0.5),
Manual(BerlinTime(2023, 3, 25, 12), 1.5),
Reading(1, Month(2023, 3), 2),
Reading(1, Month(2023, 4), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2)],
TimeZone = Berlin,
};
var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
Assert.Equal(1.5, byMonth["2023-03"], 6);
Assert.Equal(13, byMonth["2023-04"], 6);
}
[Fact]
public void A_reset_at_a_month_rows_stamp_applies_where_the_month_begins()
{
// A reset posted at 00:00 UTC on 1 August, with the August row and two live August readings.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2026, 7), 699),
Manual(BerlinTime(2026, 8, 5, 12), 5),
Manual(BerlinTime(2026, 8, 20, 12), 20),
Reading(1, Month(2026, 8), 31),
],
Events = [Reset(1, Month(2026, 8), newValue: 0, prevValue: 700)],
TimeZone = Berlin,
};
var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
Assert.Equal(32, byMonth["2026-08"], 6);
}
[Fact]
public void Burner_hours_follow_the_same_month_rows_as_registers()
{
// "Juli" = 960 h and "August" = 1000 h imported, plus live readings on 15 August and 16 September.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" },
Readings =
[
Reading(1, Month(2026, 7), 960),
Reading(1, Month(2026, 8), 1000),
Manual(BerlinTime(2026, 8, 15, 12), 990),
Manual(BerlinTime(2026, 9, 16, 12), 1020),
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(1020, result.Sum(c => c.Amount), 6);
Assert.DoesNotContain(result, c => c.Amount < 0);
}
[Fact]
public void Behind_utc_month_rows_of_deltas_and_hours_stay_in_their_own_month()
{
var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM");
foreach (var mode in new[] { MeterMode.DirectDelta, MeterMode.RuntimeCounter })
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "h" },
Readings = [Reading(1, Month(2026, 7), 960), Reading(1, Month(2026, 8), 1000)],
TimeZone = newYork,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(["2026-07", "2026-08"], result.Select(c => NewYorkMonth(c.Time)));
}
}
[Theory]
[InlineData("America/Asuncion", 2023, 8)]
[InlineData("America/Asuncion", 2017, 8)]
[InlineData("Europe/Volgograd", 2018, 11)]
[InlineData("Pacific/Apia", 2011, 11)]
[InlineData("Asia/Amman", 2005, 2)]
[InlineData("Asia/Damascus", 2011, 2)]
public void Months_at_a_transition_the_zone_data_contradicts_still_divide_and_finish(string zoneId, int year, int month)
{
// Zone data that reports a midnight's offset from after the change used to stall the month walk
// forever — an import or the startup rebuild that never returned.
if (!TimeZoneInfo.TryFindSystemTimeZoneById(zoneId, out var zone))
{
return;
}
var from = new DateTimeOffset(year, month, 10, 12, 0, 0, TimeSpan.Zero);
var to = from.AddMonths(3);
var work = Task.Run(() => GapAttribution.Attribute(from, to, to, 90, zone));
Assert.True(work.Wait(TimeSpan.FromSeconds(5)), "Attribution did not finish.");
Assert.Equal(90, work.Result.Sum(s => s.Amount), 6);
Assert.All(work.Result, s => Assert.True(s.Time > from && s.Time <= to, $"{s.Time:O} lies outside the interval"));
}
[Fact]
public void Where_clocks_fall_back_at_midnight_the_month_starts_at_the_first_midnight()
{
// Havana leaves daylight time at 01:00 on 1 November 2026, so 00:00 happens twice. October ends at
// the first one; stamping October's share after it would file it under November.
var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana");
var from = new DateTimeOffset(2026, 10, 20, 16, 0, 0, TimeSpan.Zero);
var to = new DateTimeOffset(2026, 11, 20, 17, 0, 0, TimeSpan.Zero);
var segments = GapAttribution.Attribute(from, to, to, 31, havana);
Assert.Equal(2, segments.Count);
var januaryShare = 31d / 59d; // 2023 is not a leap year: Jan 31 + Feb 28.
Assert.Equal(620 * januaryShare, segments[0].Amount, 6);
Assert.Equal(620, segments.Sum(s => s.Amount), 6);
Assert.True(segments[0].Time < new DateTimeOffset(2026, 11, 1, 4, 0, 0, TimeSpan.Zero), $"October's share is stamped {segments[0].Time:O}");
}
[Fact]
public void A_gap_in_a_counter_series_is_spread_and_marked_estimated()
public void A_live_reading_after_the_last_imported_row_counts_from_the_end_of_that_rows_month()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2023, 1), 1000),
Reading(1, Month(2023, 4), 1900), // three months in one reading
],
};
var result = _engine.Normalize(ctx).ToList();
// Baseline row for the first reading, then Jan/Feb/Mar shares of the 900 gap.
Assert.Equal(4, result.Count);
Assert.Equal(1000 + 900, result.Sum(c => c.Amount), 6);
var spread = result.Skip(1).ToList();
Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
Assert.Equal(900, spread.Sum(c => c.Amount), 6);
}
[Fact]
public void An_ordinary_monthly_series_produces_one_measured_row_per_reading()
{
// The regression that matters: this is the reference-data shape, and it must not gain rows
// or lose its quality markers.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2022, 9), 0),
Reading(1, Month(2022, 10), 411),
Reading(1, Month(2022, 11), 1153),
Reading(1, Month(2022, 12), 1968),
],
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(4, result.Count);
Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
}
[Fact]
public void The_observed_solar_gap_is_apportioned_across_the_months_it_covers()
{
// The case this exists for: Solar 1 read monthly to 1 May 2026, then a single live reading on
// 18 July. 714.5 kWh of generation arriving as one July row made June look like an outage.
// Solar 1 read monthly up to "Mai 2026", then one live reading on 18 July. May's use is already
// in the May row; the 714.5 kWh since belongs to June and July — not a third of it back in May.
var july18 = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh" },
@@ -131,28 +412,59 @@ public sealed class GapAttributionTests
[
Reading(1, Month(2026, 4), 10308),
Reading(1, Month(2026, 5), 10731),
Reading(1, new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero), 11445.5),
new Reading { MeterId = 1, Time = july18, Value = 11445.5, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
var gap = result.Where(c => c.Time > Month(2026, 5)).ToList();
var gap = result.Skip(2).ToList();
Assert.Equal(3, gap.Count);
Assert.Equal(["2026-06", "2026-07"], gap.Select(c => LocalMonth(c.Time)));
Assert.Equal(714.5, gap.Sum(c => c.Amount), 6);
// No single month swallows the whole gap any more.
Assert.All(gap, c => Assert.True(c.Amount < 714.5 * 0.75, $"{c.Time:yyyy-MM-dd} took {c.Amount:0.#}"));
// Generation is preserved end to end: baseline 0 → 11445.5.
var june = BerlinTime(2026, 7, 1) - BerlinTime(2026, 6, 1);
Assert.Equal(714.5 * (june / (july18 - BerlinTime(2026, 6, 1))), gap[0].Amount, 6);
Assert.Equal(11445.5, result.Sum(c => c.Amount), 6);
}
[Fact]
public void An_unchanged_register_across_a_long_gap_does_not_fan_out_into_empty_rows()
public void A_skipped_month_in_an_imported_table_is_shared_between_the_months_it_covers()
{
// "Januar" then "April": the 900 accrued over February, March and April.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 1000), Reading(1, Month(2023, 4), 1900)],
};
var spread = _engine.Normalize(ctx).Skip(1).ToList();
Assert.Equal(["2023-02", "2023-03", "2023-04"], spread.Select(c => c.Time.UtcDateTime.ToString("yyyy-MM")));
Assert.Equal(900, spread.Sum(c => c.Amount), 6);
Assert.Equal(900 * (28d / 89d), spread[0].Amount, 6); // Feb 28 + Mar 31 + Apr 30 days
Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
// April's share keeps the April row's own timestamp.
Assert.Equal(Month(2023, 4), spread[^1].Time);
}
[Fact]
public void Attribution_preserves_the_total_and_stamps_every_share_inside_its_month()
{
var from = BerlinTime(2026, 5, 20, 7);
var to = BerlinTime(2026, 8, 3, 21);
var segments = GapAttribution.Attribute(from, to, to, 1000.1, Berlin);
Assert.Equal(["2026-05", "2026-06", "2026-07", "2026-08"], segments.Select(s => LocalMonth(s.Time)));
Assert.Equal(1000.1, segments.Sum(s => s.Amount), 9);
Assert.All(segments, s => Assert.True(s.Time > from && s.Time <= to));
Assert.Equal(segments.Count, segments.Select(s => s.Time).Distinct().Count());
}
[Fact]
public void An_unchanged_register_across_months_does_not_fan_out_into_empty_rows()
{
// Nothing was used. Three rows of zero say no more than one, and would dilute the
// measured/estimated ratio on the detail page.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
@@ -166,10 +478,8 @@ public sealed class GapAttributionTests
}
[Fact]
public void A_rejected_decrease_across_a_long_gap_stays_a_single_row()
public void A_rejected_decrease_across_months_stays_a_single_row()
{
// The decrease branch already yields 0 and rebaselines; spreading that zero would invent
// rows for months the meter never reported.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
@@ -184,10 +494,10 @@ public sealed class GapAttributionTests
}
[Fact]
public void A_swap_across_a_long_gap_keeps_its_explicit_amount_in_one_row()
public void A_swap_across_months_keeps_its_explicit_amount_in_one_row()
{
// Swap amounts are corrections booked at the event (the water …861 → 2 case reconciles to
// 12). Apportioning one across the gap would silently rewrite a number the operator supplied.
// 12). Apportioning one would silently rewrite a number the operator supplied.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
@@ -197,6 +507,7 @@ public sealed class GapAttributionTests
Reading(1, Month(2023, 5), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
+46
View File
@@ -0,0 +1,46 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests;
/// <summary>
/// The event menu on a meter page is driven by these rules, so they pin which events each mode can
/// actually use — an event the normalizer ignores must never be offered as if it fixed something.
/// </summary>
public sealed class MeterEventRulesTests
{
[Theory]
[InlineData(MeterMode.CumulativeCounter)]
[InlineData(MeterMode.GenerationCounter)]
[InlineData(MeterMode.RuntimeCounter)]
public void Registers_offer_swap_and_reset(MeterMode mode)
{
Assert.Equal([MeterEventType.MeterSwap, MeterEventType.CounterReset, MeterEventType.Note], MeterEventRules.RecordableFor(mode));
Assert.True(MeterEventRules.IsMonotonic(mode));
Assert.True(MeterEventRules.TakesReadings(mode));
}
[Fact]
public void A_tank_offers_level_and_delivery_and_takes_no_readings()
{
Assert.Equal([MeterEventType.TankLevel, MeterEventType.Delivery, MeterEventType.Note],
MeterEventRules.RecordableFor(MeterMode.ConsumableBalance));
Assert.False(MeterEventRules.TakesReadings(MeterMode.ConsumableBalance));
Assert.False(MeterEventRules.CanRecord(MeterMode.ConsumableBalance, MeterEventType.MeterSwap));
}
[Theory]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.InstantRate)]
[InlineData(MeterMode.Virtual)]
public void Modes_without_a_register_only_take_notes(MeterMode mode) =>
Assert.Equal([MeterEventType.Note], MeterEventRules.RecordableFor(mode));
[Fact]
public void Correction_is_never_offered_because_nothing_reads_it()
{
foreach (var mode in Enum.GetValues<MeterMode>())
{
Assert.DoesNotContain(MeterEventType.Correction, MeterEventRules.RecordableFor(mode));
}
}
}
+31
View File
@@ -30,6 +30,37 @@ public sealed class RuntimeAndTankTests
Assert.Equal([0d, 334d], result.Select(c => c.Amount));
}
[Fact]
public void Runtime_counter_carries_hours_across_a_replaced_counter()
{
// Burner replaced: the old counter stopped at 8000 h (last read 7952), the new one started at
// 0 and reads 40 h a month later. Runtime is 48 + 40 h, not a lost month.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 20, Mode = MeterMode.RuntimeCounter, Unit = "h", InitialBaseline = 7785 },
Readings = [Reading(20, Month(2023, 2), 7952), Reading(20, Month(2023, 3), 40), Reading(20, Month(2023, 4), 100)],
Events = [Swap(20, Month(2023, 3), prevValue: 8000, newValue: 0)],
};
var result = _engine.Normalize(ctx);
Assert.Equal([167d, 88d, 60d], result.Select(c => c.Amount));
}
[Fact]
public void Runtime_counter_books_nothing_for_an_unexplained_decrease()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 20, Mode = MeterMode.RuntimeCounter, Unit = "h" },
Readings = [Reading(20, Month(2023, 1), 100), Reading(20, Month(2023, 2), 40), Reading(20, Month(2023, 3), 50)],
};
var result = _engine.Normalize(ctx);
Assert.Equal([100d, 0d, 10d], result.Select(c => c.Amount));
}
[Fact]
public void Tank_consumption_is_level_delta_between_dipsticks()
{
+46
View File
@@ -0,0 +1,46 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests;
/// <summary>
/// Which connector serves which source. The source dialog and the connector page's way back both lean
/// on it, and a wrong answer produces a source that saves cleanly and never ingests.
/// </summary>
public sealed class SourceRoutingTests
{
[Theory]
[InlineData(SourceType.HomeAssistant, EndpointType.HomeAssistant)]
[InlineData(SourceType.Mqtt, EndpointType.MqttBroker)]
[InlineData(SourceType.Tasmota, EndpointType.MqttBroker)]
public void Live_sources_need_a_connector(SourceType source, EndpointType endpoint)
{
Assert.Equal(endpoint, SourceRouting.RequiredEndpoint(source));
Assert.True(SourceRouting.Serves(endpoint, source));
}
[Theory]
[InlineData(SourceType.Manual)]
[InlineData(SourceType.Import)]
[InlineData(SourceType.Virtual)]
public void Other_sources_need_none(SourceType source)
{
Assert.Null(SourceRouting.RequiredEndpoint(source));
Assert.All(Enum.GetValues<EndpointType>(), endpoint => Assert.False(SourceRouting.Serves(endpoint, source)));
}
[Fact]
public void A_connector_never_serves_the_other_kind()
{
Assert.False(SourceRouting.Serves(EndpointType.HomeAssistant, SourceType.Tasmota));
Assert.False(SourceRouting.Serves(EndpointType.MqttBroker, SourceType.HomeAssistant));
}
[Fact]
public void Every_connector_kind_defaults_to_a_source_it_serves()
{
foreach (var endpoint in Enum.GetValues<EndpointType>())
{
Assert.True(SourceRouting.Serves(endpoint, SourceRouting.DefaultSourceFor(endpoint)));
}
}
}
+16 -1
View File
@@ -8,7 +8,21 @@ internal static class TestData
public static DateTimeOffset Month(int year, int month) =>
new(new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc));
/// <summary>
/// An imported reading. At midnight UTC on the 1st it is a row of a monthly table ("Mai 2026"), flagged
/// the way the importer flags month names — the shape of the reference data these tests model.
/// </summary>
public static Reading Reading(int meterId, DateTimeOffset time, double value) => new()
{
MeterId = meterId,
Time = time,
Value = value,
Quality = ReadingQuality.Imported,
Flags = time.UtcDateTime is { Day: 1, TimeOfDay.Ticks: 0 } ? ReadingFlags.MonthLabel : ReadingFlags.None,
};
/// <summary>An imported reading from a day-dated row ("01.08.2026"): an instant, whatever its clock time.</summary>
public static Reading DayReading(int meterId, DateTimeOffset time, double value) => new()
{
MeterId = meterId,
Time = time,
@@ -27,11 +41,12 @@ internal static class TestData
Amount = amount,
};
public static MeterEvent Reset(int meterId, DateTimeOffset time, double? newValue = 0) => new()
public static MeterEvent Reset(int meterId, DateTimeOffset time, double? newValue = 0, double? prevValue = null) => new()
{
MeterId = meterId,
Time = time,
EventType = MeterEventType.CounterReset,
PrevValue = prevValue,
NewValue = newValue,
};
+33
View File
@@ -0,0 +1,33 @@
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The dashboard's empty state names the first missing step of the cost setup. Pure, so no Docker.
/// </summary>
public sealed class CostSetupTests
{
[Fact]
public void A_fresh_instance_starts_with_a_meter() =>
Assert.Equal(CostSetupGap.NoMeters, new CostSetup(false, false, false, false, false).FirstGap);
[Fact]
public void The_steps_are_reported_in_setup_order()
{
Assert.Equal(CostSetupGap.NoCategories, new CostSetup(true, false, false, false, false).FirstGap);
Assert.Equal(CostSetupGap.NoMembers, new CostSetup(true, true, false, false, false).FirstGap);
Assert.Equal(CostSetupGap.NoTariffs, new CostSetup(true, true, true, false, false).FirstGap);
Assert.Equal(CostSetupGap.None, new CostSetup(true, true, true, true, false).FirstGap);
}
[Fact]
public void Manual_costs_stand_in_for_meters_only_where_there_are_none()
{
Assert.Equal(CostSetupGap.None, new CostSetup(false, true, false, false, true).FirstGap);
Assert.Equal(CostSetupGap.NoCategories, new CostSetup(false, false, false, false, true).FirstGap);
// Old manual costs do not hide what the meters are missing.
Assert.Equal(CostSetupGap.NoMembers, new CostSetup(true, true, false, false, true).FirstGap);
Assert.Equal(CostSetupGap.NoTariffs, new CostSetup(true, true, true, false, true).FirstGap);
}
}
@@ -1,3 +1,4 @@
using MeterVault.App;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
@@ -112,6 +113,44 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
.Content.ReadAsStringAsync();
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
// ...and so is every other per-meter task, from the page header: recording a swap or
// reset, and editing the meter itself.
Assert.Contains("Record event", meterPage, StringComparison.Ordinal);
Assert.Contains("Edit meter", meterPage, StringComparison.Ordinal);
// A deep link into a tab and an action renders — the action itself only opens once the
// page is interactive, which a prerender request never is.
(await client.GetAsync(new Uri($"/meters/{hausId}?tab=events&action=swap", UriKind.Relative))).EnsureSuccessStatusCode();
// The way back from setting up a connector for a source: the connector page names the meter
// and links to its source dialog, and lists which meters each connector serves.
var connectorsForMeter = System.Net.WebUtility.HtmlDecode(await client.GetStringAsync(new Uri(
MeterLinks.NewConnector(hausId, sourceId: null, MeterVault.Core.Domain.SourceType.HomeAssistant, MeterVault.Core.Domain.EndpointType.HomeAssistant),
UriKind.Relative)));
Assert.Contains("Back to 'Zähler Haus'", connectorsForMeter, StringComparison.Ordinal);
Assert.Contains("Used by", connectorsForMeter, StringComparison.Ordinal);
(await client.GetAsync(new Uri(MeterLinks.Source(hausId, sourceType: MeterVault.Core.Domain.SourceType.Mqtt, connectorId: 1), UriKind.Relative)))
.EnsureSuccessStatusCode();
// Each import batch links the meters it wrote to.
var importPage = await client.GetStringAsync(new Uri("/import", UriKind.Relative));
Assert.Contains($"href=\"/meters/{hausId}\"", importPage, StringComparison.Ordinal);
// Regression: /trends started with its load guard set, so it never left the spinner.
var trends = await client.GetStringAsync(new Uri("/trends", UriKind.Relative));
Assert.Contains("Total over range", trends, StringComparison.Ordinal);
// A tank's page leads with the entry that drives it (a tank level), not a reading nothing reads.
int tankId;
await using (var db = fx.CreateContext())
{
tankId = await db.Meters.Where(m => m.Mode == MeterVault.Core.Domain.MeterMode.ConsumableBalance).Select(m => m.Id).FirstAsync();
}
var tankPage = await client.GetStringAsync(new Uri($"/meters/{tankId}", UriKind.Relative));
Assert.Contains("Record tank level", tankPage, StringComparison.Ordinal);
var consumablesPage = await client.GetStringAsync(new Uri("/consumables", UriKind.Relative));
Assert.Contains($"/meters/{tankId}?tab=events&amp;action=delivery", consumablesPage, StringComparison.Ordinal);
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
// this covers the labels that only exist once rows have rendered — the branch a
@@ -0,0 +1,50 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Import;
namespace MeterVault.Integration.Tests.Import;
/// <summary>
/// The importer is the only place that still sees whether a date cell named a month or a day, so it is
/// where a row becomes a month label. Both are stamped at midnight on the 1st; only the month means
/// "the register at the end of that month". Pure, so no Docker.
/// </summary>
public sealed class MonthLabelImportTests
{
[Theory]
[InlineData(DateKind.MonthName, "August 2026", true)]
[InlineData(DateKind.DayDotMonthYear, "01.08.2026", false)]
[InlineData(DateKind.Auto, "August 2026", true)]
[InlineData(DateKind.Auto, "01.08.2026", false)]
public void A_row_is_a_month_label_only_when_its_date_named_a_month(DateKind kind, string date, bool label)
{
var reading = Assert.Single(Stage(kind, anchorToEnd: false, $"{date},700").Readings);
Assert.Equal(new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), reading.Time);
Assert.Equal(label, reading.Flags.HasFlag(ReadingFlags.MonthLabel));
}
[Fact]
public void A_month_anchored_to_its_last_day_is_stamped_where_it_belongs_and_is_no_label()
{
var reading = Assert.Single(Stage(DateKind.Auto, anchorToEnd: true, "August 2026,700").Readings);
Assert.Equal(new DateTimeOffset(2026, 8, 31, 0, 0, 0, TimeSpan.Zero), reading.Time);
Assert.False(reading.Flags.HasFlag(ReadingFlags.MonthLabel));
}
private static StagedImport Stage(DateKind kind, bool anchorToEnd, string row)
{
var profile = new MappingProfile
{
Name = "test",
DateColumn = 0,
DateKind = kind,
AnchorMonthsToEnd = anchorToEnd,
FirstDataRowIndex = 0,
Columns = [new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = 1, Unit = "m3" }],
};
using var reader = new StringReader(row);
return new CsvImporter().Stage(profile, reader);
}
}
@@ -0,0 +1,427 @@
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();
}
}
@@ -0,0 +1,347 @@
using Dapper;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeterVault.Integration.Tests.Ingestion;
/// <summary>
/// Consumption between two readings shows up in the months it accrued in — checked the way the charts
/// see it, bucketed by the database in the instance timezone — and stored data built under the old rule
/// is rebuilt once instead of waiting for each meter's next reading.
/// </summary>
[Collection("Timescale")]
public sealed class MonthAttributionTests(TimescaleFixture fx)
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>A Berlin wall-clock time as the UTC instant the database stores.</summary>
private static DateTimeOffset BerlinTime(int year, int month, int day, int hour) =>
new DateTimeOffset(new DateTime(year, month, day, hour, 0, 0), Berlin.GetUtcOffset(new DateTime(year, month, day, hour, 0, 0)))
.ToUniversalTime();
[Fact]
public async Task A_reading_six_weeks_after_the_last_fills_both_months_it_covers()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db);
var ingestion = new IngestionService(db, BerlinNormalization(db));
var august1 = BerlinTime(2026, 8, 1, 9);
var september16 = BerlinTime(2026, 9, 16, 18);
await ingestion.IngestByMeterAsync(meterId, august1, 700, quality: ReadingQuality.Manual);
await ingestion.IngestByMeterAsync(meterId, september16, 746, quality: ReadingQuality.Manual);
var months = await MonthlyAsync(db, meterId);
var expectedAugust = 46 * ((BerlinTime(2026, 9, 1, 0) - august1) / (september16 - august1));
// August also holds the first reading's 700, counted from the meter's zero baseline.
Assert.Equal(700 + expectedAugust, months[new DateOnly(2026, 8, 1)], 6);
Assert.Equal(46 - expectedAugust, months[new DateOnly(2026, 9, 1)], 6);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Stored_consumption_from_an_older_revision_is_rebuilt_once()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db);
db.Readings.AddRange(
new Reading { MeterId = meterId, Time = BerlinTime(2026, 8, 1, 9), Value = 700, Quality = ReadingQuality.Manual },
new Reading { MeterId = meterId, Time = BerlinTime(2026, 9, 16, 18), Value = 746, Quality = ReadingQuality.Manual });
// What the previous rule stored: the whole six weeks on the September reading.
db.Consumption.AddRange(
new Consumption { MeterId = meterId, Time = BerlinTime(2026, 8, 1, 9), Amount = 700, Quality = ReadingQuality.Manual },
new Consumption { MeterId = meterId, Time = BerlinTime(2026, 9, 16, 18), Amount = 46, Quality = ReadingQuality.Manual });
await SetRevisionAsync(db, "1");
var upgrade = new NormalizationUpgrade(db, BerlinNormalization(db), NullLogger<NormalizationUpgrade>.Instance);
var rebuilt = await upgrade.RunAsync();
Assert.True(rebuilt >= 1);
var months = await MonthlyAsync(db, meterId);
Assert.True(months[new DateOnly(2026, 8, 1)] > 700 + 30, "August did not get its share back.");
var stored = await db.AppSettings.AsNoTracking().SingleAsync(s => s.Key == NormalizationUpgrade.SettingKey);
Assert.Equal(NormalizationUpgrade.CurrentRevision.ToString(System.Globalization.CultureInfo.InvariantCulture), stored.Value);
// Up to date now: the next start does nothing.
Assert.Equal(0, await new NormalizationUpgrade(db, BerlinNormalization(db), NullLogger<NormalizationUpgrade>.Instance).RunAsync());
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_meter_that_cannot_be_rebuilt_neither_stops_startup_nor_the_other_meters()
{
await using var db = fx.CreateContext();
var good = await CreateMeterAsync(db);
var broken = await CreateMeterAsync(db);
db.Readings.AddRange(
new Reading { MeterId = good, Time = BerlinTime(2026, 8, 1, 9), Value = 700, Quality = ReadingQuality.Manual },
new Reading { MeterId = good, Time = BerlinTime(2026, 9, 16, 18), Value = 746, Quality = ReadingQuality.Manual });
await db.SaveChangesAsync();
// A mode this build cannot read: loading the meter throws, as any unexpected data would.
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE meter SET mode = 'FromTheFuture' WHERE id = {broken}");
await SetRevisionAsync(db, "1");
try
{
var rebuilt = await Upgrade(db).RunAsync();
Assert.True(rebuilt >= 1);
Assert.True((await MonthlyAsync(db, good)).ContainsKey(new DateOnly(2026, 8, 1)), "The healthy meter was not rebuilt.");
Assert.Contains(broken, await PendingAsync(db));
// The next start retries only what failed — and once it can be read, it is rebuilt.
Assert.Equal(0, await Upgrade(db).RunAsync());
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE meter SET mode = 'CumulativeCounter' WHERE id = {broken}");
Assert.Equal(1, await Upgrade(db).RunAsync());
Assert.Empty(await PendingAsync(db));
Assert.Equal(0, await Upgrade(db).RunAsync());
}
finally
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE meter SET mode = 'CumulativeCounter' WHERE id = {broken}");
await CleanupAsync(db, good);
await CleanupAsync(db, broken);
}
}
[Fact]
public async Task Rows_of_earlier_monthly_imports_are_marked_as_months_before_the_rebuild()
{
await using var db = fx.CreateContext();
var monthly = await CreateMeterAsync(db);
var autoMonthly = await CreateMeterAsync(db);
var daily = await CreateMeterAsync(db);
var monthBatch = new ImportBatch { SourceName = "months.csv", Mapping = """{"dateKind":"MonthName"}""" };
// The wizard's default: auto-detected dates, which do not say whether a row named a month or a day.
var autoMonthBatch = new ImportBatch { SourceName = "auto-months.csv", Mapping = """{"dateKind":"Auto"}""" };
var dayBatch = new ImportBatch { SourceName = "days.csv", Mapping = """{"dateKind":"Auto"}""" };
db.ImportBatches.AddRange(monthBatch, autoMonthBatch, dayBatch);
await db.SaveChangesAsync();
// As an import before revision 2 stored them: nothing but the midnight stamp on the 1st.
foreach (var (meter, batch) in new[] { (monthly, monthBatch.Id), (autoMonthly, autoMonthBatch.Id), (daily, dayBatch.Id) })
{
db.Readings.AddRange(
new Reading { MeterId = meter, Time = Utc(2026, 6, 1), Value = 100, Quality = ReadingQuality.Imported, ImportBatchId = batch },
new Reading { MeterId = meter, Time = Utc(2026, 7, 1), Value = 130, Quality = ReadingQuality.Imported, ImportBatchId = batch });
}
// A reading off the 1st gives the auto-detected batch away as day-dated.
db.Readings.Add(new Reading { MeterId = daily, Time = Utc(2026, 7, 15), Value = 140, Quality = ReadingQuality.Imported, ImportBatchId = dayBatch.Id });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
await SetRevisionAsync(db, "1");
try
{
await Upgrade(db).RunAsync();
var flags = await db.Readings.AsNoTracking()
.Where(r => r.MeterId == monthly || r.MeterId == autoMonthly || r.MeterId == daily)
.Select(r => new { r.MeterId, r.Flags })
.ToListAsync();
Assert.All(flags.Where(f => f.MeterId == monthly), f => Assert.True(f.Flags.HasFlag(ReadingFlags.MonthLabel)));
// Every row on the 1st across months: a monthly table, as those rows were always attributed.
Assert.All(flags.Where(f => f.MeterId == autoMonthly), f => Assert.True(f.Flags.HasFlag(ReadingFlags.MonthLabel)));
Assert.All(flags.Where(f => f.MeterId == daily), f => Assert.False(f.Flags.HasFlag(ReadingFlags.MonthLabel)));
// "Juli 2026" now books July's 30 under July, not June.
Assert.Equal(30, (await MonthlyAsync(db, monthly))[new DateOnly(2026, 7, 1)], 6);
Assert.Equal(30, (await MonthlyAsync(db, autoMonthly))[new DateOnly(2026, 7, 1)], 6);
}
finally
{
await CleanupAsync(db, monthly);
await CleanupAsync(db, autoMonthly);
await CleanupAsync(db, daily);
await db.ImportBatches.Where(b => b.Id == monthBatch.Id || b.Id == autoMonthBatch.Id || b.Id == dayBatch.Id).ExecuteDeleteAsync();
}
}
[Fact]
public async Task Costs_are_bucketed_in_the_zone_months_are_divided_in()
{
// London is an hour behind Berlin: August's share is stamped at 23:59:59 London time, which is
// already September in Berlin. Bucketed in a hard-coded Berlin, the fix would not show.
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db);
var london = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = "Europe/London" });
var ingestion = new IngestionService(db, new NormalizationService(db, NormalizationEngine.CreateDefault(), london));
await ingestion.IngestByMeterAsync(meterId, Utc(2026, 8, 1).AddHours(8), 700, quality: ReadingQuality.Manual);
await ingestion.IngestByMeterAsync(meterId, Utc(2026, 9, 16).AddHours(17), 746, quality: ReadingQuality.Manual);
try
{
var costs = await new CostService(fx, london).GetMeterCostsAsync(meterId, Utc(2026, 7, 1), Utc(2026, 10, 1));
var august = Assert.Single(costs, c => c.Period == new DateOnly(2026, 8, 1));
var september = Assert.Single(costs, c => c.Period == new DateOnly(2026, 9, 1));
Assert.True(august.Consumption > 730, $"August holds {august.Consumption}");
Assert.True(september.Consumption < 16, $"September holds {september.Consumption}");
}
finally
{
await CleanupAsync(db, meterId);
}
}
[Fact]
public async Task A_reading_inside_an_imported_month_is_judged_against_the_month_before_it()
{
// "August 2026" = 731 is stamped on 1 August but is the register on 31 August. A photo of the meter
// from 20 August showing 720 is not a drop from 731.
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db);
db.Readings.AddRange(
new Reading { MeterId = meterId, Time = Utc(2026, 7, 1), Value = 699, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel },
new Reading { MeterId = meterId, Time = Utc(2026, 8, 1), Value = 731, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var ingestion = new IngestionService(db, BerlinNormalization(db));
try
{
Assert.Equal(IngestionOutcome.Written, await ingestion.IngestByMeterAsync(meterId, BerlinTime(2026, 8, 20, 9), 720, quality: ReadingQuality.Manual));
// ...while a real drop below July's register still is one.
Assert.Equal(IngestionOutcome.RejectedDecrease, await ingestion.IngestByMeterAsync(meterId, BerlinTime(2026, 8, 21, 9), 650, quality: ReadingQuality.Manual));
var months = await MonthlyAsync(db, meterId);
Assert.Equal(32, months[new DateOnly(2026, 8, 1)], 6);
}
finally
{
await CleanupAsync(db, meterId);
}
}
[Fact]
public async Task A_live_value_written_onto_a_month_row_turns_it_into_a_reading_at_that_instant()
{
await using var db = fx.CreateContext();
var meterId = await CreateMeterAsync(db);
db.Readings.AddRange(
new Reading { MeterId = meterId, Time = Utc(2026, 7, 1), Value = 699, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel },
new Reading { MeterId = meterId, Time = Utc(2026, 8, 1), Value = 731, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var ingestion = new IngestionService(db, BerlinNormalization(db));
try
{
// A hand correction keeps the month row a month row...
await ingestion.IngestByMeterAsync(meterId, Utc(2026, 7, 1), 700, quality: ReadingQuality.Manual);
// ...an API/HA value at that instant is what the register showed then.
await ingestion.IngestByMeterAsync(meterId, Utc(2026, 8, 1), 702);
var rows = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToListAsync();
Assert.True(rows[0].Flags.HasFlag(ReadingFlags.MonthLabel));
Assert.False(rows[1].Flags.HasFlag(ReadingFlags.MonthLabel));
Assert.Equal(ReadingQuality.Measured, rows[1].Quality);
}
finally
{
await CleanupAsync(db, meterId);
}
}
[Fact]
public async Task Behind_utc_a_period_starts_at_local_midnight_so_month_end_shares_stay_in_it()
{
// New York: December's share of a 10 December to 20 January interval is stamped at 23:59:59 on
// 31 December local, which is already 1 January in UTC. A year requested as UTC midnights lost it.
await using var db = fx.CreateContext();
var newYork = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = "America/New_York" });
var type = new EnergyType { Key = $"flow-{Guid.NewGuid():N}", DisplayName = "Flow test", BaseUnit = "m3", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
var meter = new Meter { Name = $"ny-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "m3" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
var ingestion = new IngestionService(db, new NormalizationService(db, NormalizationEngine.CreateDefault(), newYork));
await ingestion.IngestByMeterAsync(meter.Id, new DateTimeOffset(2026, 12, 10, 17, 0, 0, TimeSpan.Zero), 100, quality: ReadingQuality.Manual);
await ingestion.IngestByMeterAsync(meter.Id, new DateTimeOffset(2027, 1, 20, 17, 0, 0, TimeSpan.Zero), 141, quality: ReadingQuality.Manual);
try
{
var december = await new MeterVault.Infrastructure.Dashboard.FlowService(fx, newYork)
.GetFlowAsync(type.Id, new DateOnly(2026, 12, 1), new DateOnly(2027, 1, 1));
var node = Assert.Single(december.Nodes, n => n.MeterId == meter.Id);
// The first reading's 100 plus December's 21.5 of the 41 days' 41 m3 (noon on the 10th to midnight).
Assert.Equal(121.5, node.Value, 3);
}
finally
{
await CleanupAsync(db, meter.Id);
await db.EnergyTypes.Where(t => t.Id == type.Id).ExecuteDeleteAsync();
}
}
private static DateTimeOffset Utc(int year, int month, int day) => new(year, month, day, 0, 0, 0, TimeSpan.Zero);
private static NormalizationUpgrade Upgrade(MeterVaultDbContext db) =>
new(db, BerlinNormalization(db), NullLogger<NormalizationUpgrade>.Instance);
private static async Task<int[]> PendingAsync(MeterVaultDbContext db)
{
var setting = await db.AppSettings.AsNoTracking().FirstOrDefaultAsync(s => s.Key == NormalizationUpgrade.PendingSettingKey);
return setting is null ? [] : System.Text.Json.JsonSerializer.Deserialize<int[]>(setting.Value) ?? [];
}
private static NormalizationService BerlinNormalization(MeterVaultDbContext db) =>
new(db, NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = "Europe/Berlin" }));
private static async Task<Dictionary<DateOnly, double>> MonthlyAsync(MeterVaultDbContext db, int meterId)
{
const string sql =
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
"sum(amount) AS amount FROM consumption WHERE meter_id = @meterId GROUP BY period ORDER BY period";
var rows = await db.Database.GetDbConnection().QueryAsync<(DateOnly Period, double Amount)>(sql, new { meterId });
return rows.ToDictionary(r => r.Period, r => r.Amount);
}
private static async Task SetRevisionAsync(MeterVaultDbContext db, string revision)
{
var setting = await db.AppSettings.FirstOrDefaultAsync(s => s.Key == NormalizationUpgrade.SettingKey);
if (setting is null)
{
db.AppSettings.Add(new AppSetting { Key = NormalizationUpgrade.SettingKey, Value = revision });
}
else
{
setting.Value = revision;
}
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
}
private static async Task<int> CreateMeterAsync(MeterVaultDbContext db)
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "water");
var meter = new Meter { Name = $"months-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "m3" };
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.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}
@@ -0,0 +1,36 @@
using MeterVault.Infrastructure.Options;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The configured zone id reaches .NET and PostgreSQL alike. Pure, so no Docker.
/// </summary>
public sealed class InstanceTimeZoneTests
{
[Fact]
public void A_windows_zone_id_is_turned_into_the_iana_id_postgres_understands()
{
if (!TimeZoneInfo.TryFindSystemTimeZoneById("W. Europe Standard Time", out _))
{
return; // No Windows-id support on this host (no ICU): nothing to translate.
}
Assert.Equal("Europe/Berlin", InstanceTimeZone.Canonical("W. Europe Standard Time"));
}
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
[InlineData("Not/AZone")]
public void Iana_and_unknown_ids_are_left_as_they_are(string id) =>
Assert.Equal(id, InstanceTimeZone.Canonical(id));
[Fact]
public void A_local_date_starts_at_its_local_midnight()
{
var berlin = InstanceTimeZone.Resolve("Europe/Berlin");
Assert.Equal(new DateTimeOffset(2026, 7, 31, 22, 0, 0, TimeSpan.Zero), InstanceTimeZone.StartOf(new DateOnly(2026, 8, 1), berlin));
Assert.Equal(TimeZoneInfo.Utc, InstanceTimeZone.Resolve("Not/AZone"));
}
}
@@ -0,0 +1,103 @@
using MeterVault.App;
using MeterVault.Core.Domain;
namespace MeterVault.Integration.Tests;
/// <summary>
/// Hand-entered timestamps and meter-page addresses: the pure pieces behind the reading and event
/// dialogs and the deep links into a meter. No database, so these run without Docker.
/// </summary>
public sealed class LocalTimeEntryTests
{
private static readonly TimeZoneInfo Berlin = LocalTimeEntry.Resolve("Europe/Berlin");
[Fact]
public void Wall_clock_time_is_read_in_the_instance_timezone()
{
var entry = new LocalTimeEntry(Berlin) { Date = new DateTime(2026, 9, 17), TimeOfDay = new TimeSpan(9, 30, 0) };
Assert.Equal(new DateTimeOffset(2026, 9, 17, 7, 30, 0, TimeSpan.Zero), entry.Utc); // CEST = UTC+2
}
[Fact]
public void A_spring_forward_time_names_no_instant()
{
var entry = new LocalTimeEntry(Berlin) { Date = new DateTime(2026, 3, 29), TimeOfDay = new TimeSpan(2, 30, 0) };
Assert.True(entry.IsSkipped);
Assert.Null(entry.Utc);
}
[Fact]
public void An_ambiguous_autumn_time_resolves_to_standard_time()
{
var entry = new LocalTimeEntry(Berlin) { Date = new DateTime(2026, 10, 25), TimeOfDay = new TimeSpan(2, 30, 0) };
Assert.Equal(new DateTimeOffset(2026, 10, 25, 1, 30, 0, TimeSpan.Zero), entry.Utc); // CET = UTC+1
}
[Fact]
public void Setting_an_instant_round_trips_to_the_minute()
{
var entry = new LocalTimeEntry(Berlin);
entry.Set(new DateTimeOffset(2026, 9, 17, 7, 30, 42, TimeSpan.Zero));
Assert.Equal(new DateTime(2026, 9, 17), entry.Date);
Assert.Equal(new TimeSpan(9, 30, 0), entry.TimeOfDay);
Assert.Equal(new DateTimeOffset(2026, 9, 17, 7, 30, 0, TimeSpan.Zero), entry.Utc);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Not/AZone")]
public void An_unknown_timezone_falls_back_to_utc(string? id) =>
Assert.Equal(TimeZoneInfo.Utc, LocalTimeEntry.Resolve(id));
[Fact]
public void Meter_links_address_a_tab_and_an_action()
{
Assert.Equal("/meters/7", MeterLinks.Detail(7));
Assert.Equal("/meters/7?tab=events&action=swap", MeterLinks.Event(7, MeterEventType.MeterSwap));
Assert.Equal("/meters/7?tab=readings&action=reading", MeterLinks.QuickEntry(7, MeterMode.CumulativeCounter));
Assert.Equal("/meters/7?tab=events&action=tank-level", MeterLinks.QuickEntry(7, MeterMode.ConsumableBalance));
Assert.Null(MeterLinks.QuickEntry(7, MeterMode.Virtual));
}
[Fact]
public void Source_and_connector_links_carry_the_way_back()
{
Assert.Equal("/meters/7?tab=sources&action=source", MeterLinks.Source(7));
Assert.Equal(
"/meters/7?tab=sources&action=source&source=3&type=Tasmota&connector=12",
MeterLinks.Source(7, sourceId: 3, sourceType: SourceType.Tasmota, connectorId: 12));
Assert.Equal(
"/admin/connectors?new=MqttBroker&meter=7&type=Tasmota",
MeterLinks.NewConnector(7, sourceId: null, SourceType.Tasmota, EndpointType.MqttBroker));
Assert.Equal(
"/admin/connectors?edit=12&meter=7&source=3&type=HomeAssistant",
MeterLinks.EditConnector(7, sourceId: 3, SourceType.HomeAssistant, connectorId: 12));
}
[Fact]
public void Every_event_type_round_trips_through_its_action()
{
foreach (var type in Enum.GetValues<MeterEventType>())
{
Assert.Equal(type, MeterLinks.EventFor(MeterLinks.ActionFor(type)));
}
Assert.Null(MeterLinks.EventFor(MeterLinks.ActionReading));
Assert.Null(MeterLinks.EventFor(null));
}
[Theory]
[InlineData(null, 0)]
[InlineData("readings", 0)]
[InlineData("EVENTS", 2)]
[InlineData("sources", 4)]
[InlineData("nonsense", 0)]
public void Tab_keys_map_to_panel_indexes(string? tab, int expected) =>
Assert.Equal(expected, MeterLinks.TabIndex(tab));
}
@@ -49,6 +49,18 @@ public sealed class ReadingEntryTests
Assert.Equal(12345.6, entry.Value!.Value, 9);
}
[Fact]
public void Prefill_keeps_every_decimal_a_sensor_reported()
{
// Rounded to three decimals, 861.1234 would prefill as 861.123 — below the stored reading, so
// the dialog would flag its own untouched prefill as a decrease.
var entry = new ReadingEntry();
entry.Prefill(861.1234);
Assert.Equal("861,1234", entry.Text);
Assert.Equal(861.1234, entry.Value!.Value, 9);
}
[Fact]
public void Prefill_does_not_invent_precision_the_meter_never_reported()
{
@@ -6,48 +6,68 @@ using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Reconciliation;
/// <summary>
/// Gap splitting apportions a long unread stretch across the months it covers. The reference sheets
/// are read monthly and must never trigger it, or their months would silently shift and the whole
/// golden-fixture oracle (SDD §13) would be measuring the splitter instead of the normalizer.
/// Month attribution divides an interval that crosses a month boundary. The reference sheets are
/// monthly tables whose rows each carry exactly their own month, and must never be divided, or their
/// months would silently shift and the whole golden-fixture oracle (SDD §13) would be measuring the
/// attribution instead of the normalizer.
/// </summary>
/// <remarks>
/// The reconciliation suites already compare month by month, so a spurious split would surface there
/// as a numeric failure. This asserts the mechanism directly instead of relying on that side effect:
/// it proves the rule was evaluated against real fixture cadence and declined to fire, rather than
/// the fixtures simply having no gaps to find.
/// The reconciliation suites already compare month by month, so a spurious division would surface
/// there as a numeric failure. This asserts the mechanism directly instead of relying on that side
/// effect — and in the instance timezone, where month boundaries sit an hour or two away from the UTC
/// midnights the importer stamps rows at.
/// </remarks>
public sealed class GapSplittingIsInertOnFixturesTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
[Theory]
[InlineData(ReferenceProfiles.Haus, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Netz, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Auto, MeterMode.CumulativeCounter)]
[InlineData(ReferenceProfiles.Solar1, MeterMode.GenerationCounter)]
[InlineData(ReferenceProfiles.Solar2, MeterMode.GenerationCounter)]
public void Electricity_meters_produce_exactly_one_row_per_reading(int meterId, MeterMode mode)
public void Electricity_meters_produce_exactly_one_row_per_reading_at_that_reading(int meterId, MeterMode mode)
{
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
var readings = staged.Readings.Count(r => r.MeterId == meterId);
var readings = staged.Readings.Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToList();
var computed = Normalize(staged, new MeterConfig { MeterId = meterId, Mode = mode, Unit = "kWh" });
foreach (var zone in new[] { TimeZoneInfo.Utc, Berlin })
{
var computed = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = meterId, Mode = mode, Unit = "kWh" },
Readings = readings,
TimeZone = zone,
});
Assert.True(readings > 20, $"meter {meterId}: expected a real series, got {readings} readings.");
Assert.Equal(readings, computed.Count);
Assert.True(readings.Count > 20, $"meter {meterId}: expected a real series, got {readings.Count} readings.");
Assert.Equal(readings.Count, computed.Count);
Assert.Equal(readings.Select(r => r.Time), computed.Select(c => c.Time));
}
}
[Fact]
public void No_fixture_interval_is_long_enough_to_split()
public void No_fixture_interval_is_divided()
{
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
foreach (var group in staged.Readings.GroupBy(r => r.MeterId))
{
var times = group.Select(r => r.Time).OrderBy(t => t).ToList();
for (var i = 1; i < times.Count; i++)
var readings = group.OrderBy(r => r.Time).ToList();
for (var i = 1; i < readings.Count; i++)
{
Assert.False(
GapAttribution.ShouldSplit(times[i - 1], times[i]),
$"meter {group.Key}: {times[i - 1]:yyyy-MM-dd} → {times[i]:yyyy-MM-dd} would be split.");
Assert.True(GapAttribution.IsMonthLabel(readings[i]), $"meter {group.Key}: {readings[i].Time:O} is not a month row.");
var segments = GapAttribution.Attribute(
GapAttribution.EffectiveTime(readings[i - 1], Berlin),
GapAttribution.EffectiveTime(readings[i], Berlin),
readings[i].Time,
1,
Berlin);
var only = Assert.Single(segments);
Assert.Equal(readings[i].Time, only.Time);
}
}
}