using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using static MeterVault.Core.Tests.TestData; namespace MeterVault.Core.Tests; /// /// 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. /// public sealed class GapAttributionTests { private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); [Fact] public void A_monthly_cadence_is_never_split() { // 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))); // 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))); // 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))); } [Fact] public void Sub_month_intervals_are_never_split() { 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))); } [Fact] public void A_skipped_month_is_split() { 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))); } [Fact] public void Splitting_preserves_the_total_and_keeps_the_closing_timestamp() { var start = Month(2026, 5); var end = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero); var segments = GapAttribution.Split(start, end, 714.5); // 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); } [Fact] public void Each_month_gets_a_share_proportional_to_the_time_it_covers() { // Exactly two whole months: an even split, to the cent. var segments = GapAttribution.Split(Month(2023, 1), Month(2023, 3), 620); 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); } [Fact] public void A_gap_in_a_counter_series_is_spread_and_marked_estimated() { 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. 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), Reading(1, new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero), 11445.5), ], }; var result = _engine.Normalize(ctx).ToList(); var gap = result.Where(c => c.Time > Month(2026, 5)).ToList(); Assert.Equal(3, gap.Count); 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. 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() { // 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" }, 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_a_long_gap_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" }, 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_a_long_gap_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. 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)], }; 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); } }