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();
}
}