using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Import; using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport; namespace MeterVault.Integration.Tests.Reconciliation; /// /// 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. /// /// /// 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. /// 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_at_that_reading(int meterId, MeterMode mode) { var staged = Stage(ReferenceProfiles.Electricity(), Electricity); var readings = staged.Readings.Where(r => r.MeterId == meterId).OrderBy(r => r.Time).ToList(); 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.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_divided() { var staged = Stage(ReferenceProfiles.Electricity(), Electricity); foreach (var group in staged.Readings.GroupBy(r => r.MeterId)) { var readings = group.OrderBy(r => r.Time).ToList(); for (var i = 1; i < readings.Count; i++) { 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); } } } }