using MeterVault.Core.Analysis; using MeterVault.Core.Normalization; namespace MeterVault.Core.Tests.Analysis; /// Terse builders for coverage tests: local instants, local buckets and coverage runs (Berlin unless a zone is given). internal static class CoverageTestData { public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); /// A Berlin wall-clock time as an instant (UTC). public static DateTimeOffset At(int year, int month, int day, int hour = 0, int minute = 0) => At(Berlin, year, month, day, hour, minute); /// A wall-clock time in as an instant (UTC). public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0) { var wall = new DateTime(year, month, day, hour, minute, 0); return new DateTimeOffset(wall, zone.GetUtcOffset(wall)).ToUniversalTime(); } public static DateTimeOffset Midnight(DateOnly date) => GapAttribution.LocalMidnight(date, Berlin); public static AnalysisBucket Day(int year, int month, int day) => Day(Berlin, year, month, day); public static AnalysisBucket Day(TimeZoneInfo zone, int year, int month, int day) { var date = new DateOnly(year, month, day); return Bucket(zone, date, date.AddDays(1), BucketSize.Day); } /// The Monday-start week containing the given date. public static AnalysisBucket Week(int year, int month, int day) { var date = new DateOnly(year, month, day); var monday = date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); return Bucket(Berlin, monday, monday.AddDays(7), BucketSize.Week); } public static AnalysisBucket Month(int year, int month) => Month(Berlin, year, month); public static AnalysisBucket Month(TimeZoneInfo zone, int year, int month) { var first = new DateOnly(year, month, 1); return Bucket(zone, first, first.AddMonths(1), BucketSize.Month); } public static AnalysisBucket Year(int year) { var first = new DateOnly(year, 1, 1); return Bucket(Berlin, first, first.AddYears(1), BucketSize.Year); } /// A bucket cut at : the to-date bucket of a period that ends now. public static AnalysisBucket CutAt(AnalysisBucket bucket, DateTimeOffset now) => bucket with { To = now, EndDay = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime).AddDays(1), NominalEndDay = bucket.EndDay }; public static CoverageRun Run(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false, DateTimeOffset? last = null) => new(from, to, resolution, divided, CoverageGapReason.None, last); /// A run that is one source interval: it knows its final interval starts where it does. public static CoverageRun Single(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) => Run(from, to, resolution, divided, last: from); public static CoverageRun Gap(DateTimeOffset from, DateTimeOffset to, CoverageGapReason reason) => new(from, to, ResolutionClass.Hour, false, reason); /// /// A run of imported monthly table rows ("Jan 2022" … ): each row's interval is its labelled local month, /// so the run is month-class, month-aligned, and its last interval is its last month. /// public static CoverageRun MonthLabels(int fromYear, int fromMonth, int toYear, int toMonthExclusive) => MonthLabels(Berlin, fromYear, fromMonth, toYear, toMonthExclusive); public static CoverageRun MonthLabels(TimeZoneInfo zone, int fromYear, int fromMonth, int toYear, int toMonthExclusive) { var end = new DateOnly(toYear, toMonthExclusive, 1); return Run( GapAttribution.LocalMidnight(new DateOnly(fromYear, fromMonth, 1), zone), GapAttribution.LocalMidnight(end, zone), ResolutionClass.Month, divided: true, last: GapAttribution.LocalMidnight(end.AddMonths(-1), zone)); } /// /// The calendar shift of a comparison (D-06), as the period resolver maps a cut-off: the same local wall /// time on the shifted date. A date the target month lacks (29 February, 31 April) cuts at that month's /// end; a wall time lost to DST takes the first instant after the gap; a repeated one its first occurrence. /// public static DateTimeOffset ShiftYears(DateTimeOffset instant, int years) => Shift(instant, Berlin, months: 12 * years); public static DateTimeOffset ShiftMonths(DateTimeOffset instant, int months) => Shift(instant, Berlin, months: months); public static DateTimeOffset ShiftDays(DateTimeOffset instant, int days) => Shift(instant, Berlin, days: days); public static DateTimeOffset Shift(DateTimeOffset instant, TimeZoneInfo zone, int months = 0, int days = 0) { var wall = TimeZoneInfo.ConvertTime(instant, zone).DateTime; if (days != 0) { return FromWall(wall.AddDays(days), zone); } var target = wall.AddMonths(months); return target.Day != wall.Day ? GapAttribution.LocalMidnight(new DateOnly(target.Year, target.Month, 1).AddMonths(1), zone) : FromWall(target, zone); } public static ResolvedPeriod Period(PeriodPreset preset, DateOnly firstDay, DateOnly lastDay, DateTimeOffset now) { var from = Midnight(firstDay); var endOfLastDay = Midnight(lastDay.AddDays(1)); var toDate = now < endOfLastDay; return new ResolvedPeriod(preset, firstDay, lastDay, from, toDate ? now : endOfLastDay, now, toDate, false, Berlin); } private static AnalysisBucket Bucket(TimeZoneInfo zone, DateOnly first, DateOnly end, BucketSize size) => new(first, end, GapAttribution.LocalMidnight(first, zone), GapAttribution.LocalMidnight(end, zone), size); private static DateTimeOffset FromWall(DateTime wall, TimeZoneInfo zone) { var local = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified); while (zone.IsInvalidTime(local)) { local = local.AddMinutes(1); } var offset = zone.IsAmbiguousTime(local) ? zone.GetAmbiguousTimeOffsets(local).Max() : zone.GetUtcOffset(local); return new DateTimeOffset(local, offset).ToUniversalTime(); } }