Files
MeterVault/tests/Core.Tests/GapAttributionTests.cs
T
Florian Schmidt aacdc28d70 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.
2026-09-17 21:09:17 +02:00

520 lines
23 KiB
C#

using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests;
/// <summary>
/// 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 Readings_from_1_August_to_16_September_are_divided_between_the_two_months()
{
// 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,
};
var result = _engine.Normalize(ctx).ToList();
var interval = result.Skip(1).ToList();
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 An_interval_inside_one_month_is_one_row_at_its_reading_with_its_own_quality()
{
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 Months_are_local_so_a_reading_just_after_local_midnight_does_not_take_the_month_with_it()
{
// 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 A_reading_exactly_at_local_midnight_on_the_first_books_wholly_to_the_month_before()
{
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 result = _engine.Normalize(ctx).ToList();
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 An_ordinary_imported_monthly_series_produces_one_unchanged_row_per_reading()
{
// 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);
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_live_reading_after_the_last_imported_row_counts_from_the_end_of_that_rows_month()
{
// 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" },
Readings =
[
Reading(1, Month(2026, 4), 10308),
Reading(1, Month(2026, 5), 10731),
new Reading { MeterId = 1, Time = july18, Value = 11445.5, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
var gap = result.Skip(2).ToList();
Assert.Equal(["2026-06", "2026-07"], gap.Select(c => LocalMonth(c.Time)));
Assert.Equal(714.5, gap.Sum(c => c.Amount), 6);
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 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()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 500), Reading(1, Month(2023, 5), 500)],
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(0, result[^1].Amount, 6);
}
[Fact]
public void A_rejected_decrease_across_months_stays_a_single_row()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 900), Reading(1, Month(2023, 5), 100)],
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(0, result[^1].Amount, 6);
Assert.Equal(Month(2023, 5), result[^1].Time);
}
[Fact]
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 would silently rewrite a number the operator supplied.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings =
[
Reading(1, Month(2023, 1), 861),
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();
Assert.Equal(2, result.Count);
Assert.Equal(12, result[^1].Amount, 6);
Assert.NotEqual(ReadingQuality.Estimated, result[^1].Quality);
}
}