ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator.
503 lines
21 KiB
C#
503 lines
21 KiB
C#
using MeterVault.Core.Analysis;
|
||
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
|
||
|
||
namespace MeterVault.Core.Tests.Analysis;
|
||
|
||
/// <summary>
|
||
/// Buckets are local calendar units clipped to the period (D-05). What these pin down: "last 12 months" is
|
||
/// exactly 12 buckets ending at now; weeks start on Monday; DST days are 23 or 25 hours; Auto picks one
|
||
/// sensible size that never undercuts the data's resolution; an explicit size over 400 points is refused
|
||
/// with a coarser suggestion rather than truncated.
|
||
/// </summary>
|
||
public sealed class BucketPlannerTests
|
||
{
|
||
private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37);
|
||
|
||
private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
|
||
PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin);
|
||
|
||
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
|
||
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, zone ?? Berlin);
|
||
|
||
private static void AssertTiles(ResolvedPeriod period, IReadOnlyList<AnalysisBucket> buckets)
|
||
{
|
||
Assert.NotEmpty(buckets);
|
||
Assert.Equal(period.From, buckets[0].From);
|
||
Assert.Equal(period.To, buckets[^1].To);
|
||
Assert.Equal(period.FirstDay, buckets[0].FirstDay);
|
||
for (var i = 1; i < buckets.Count; i++)
|
||
{
|
||
Assert.Equal(buckets[i - 1].To, buckets[i].From);
|
||
Assert.Equal(buckets[i - 1].EndDay, buckets[i].FirstDay);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void The_last_12_months_give_exactly_12_month_buckets_the_last_one_ending_now()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(12, plan.Buckets.Count);
|
||
Assert.Equal(12, plan.PointCount);
|
||
AssertTiles(period, plan.Buckets);
|
||
Assert.Equal(
|
||
["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"],
|
||
plan.Buckets.Select(b => $"{b.FirstDay.Year:D4}-{b.FirstDay.Month:D2}"));
|
||
|
||
var october = plan.Buckets[0];
|
||
Assert.Equal((Day(2025, 10, 1), Day(2025, 11, 1)), (october.FirstDay, october.EndDay));
|
||
Assert.Equal((Utc(2025, 9, 30, 22), Utc(2025, 10, 31, 23)), (october.From, october.To));
|
||
|
||
var september = plan.Buckets[^1];
|
||
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (september.FirstDay, september.EndDay));
|
||
Assert.Equal(September19, september.To);
|
||
Assert.All(plan.Buckets, b => Assert.Equal(BucketSize.Month, b.Size));
|
||
}
|
||
|
||
[Fact]
|
||
public void Across_New_Year_the_last_12_months_still_give_12_buckets_ending_with_the_new_January()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2027, 1, 1, 0, 30));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.Equal(12, plan.Buckets.Count);
|
||
Assert.Equal(Day(2026, 2, 1), plan.Buckets[0].FirstDay);
|
||
Assert.Equal(Day(2027, 1, 1), plan.Buckets[^1].FirstDay);
|
||
Assert.Equal(TimeSpan.FromMinutes(30), plan.Buckets[^1].To - plan.Buckets[^1].From);
|
||
}
|
||
|
||
[Fact]
|
||
public void At_the_exact_midnight_that_starts_a_month_the_last_12_months_keep_12_buckets_the_new_one_empty()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.Equal(12, plan.Buckets.Count);
|
||
Assert.Equal(Day(2026, 10, 1), plan.Buckets[^1].FirstDay);
|
||
Assert.Equal(plan.Buckets[^1].From, plan.Buckets[^1].To);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(BucketSize.Day)]
|
||
[InlineData(BucketSize.Week)]
|
||
[InlineData(BucketSize.Month)]
|
||
[InlineData(BucketSize.Auto)]
|
||
public void At_the_exact_midnight_that_starts_a_month_month_to_date_has_one_empty_bucket_like_the_last_12_months(BucketSize size)
|
||
{
|
||
var now = At(Berlin, 2026, 10, 1);
|
||
var monthToDate = Preset(PeriodPreset.MonthToDate, now);
|
||
var last12 = Preset(PeriodPreset.Last12Months, now);
|
||
|
||
var plan = BucketPlanner.Plan(monthToDate, size);
|
||
|
||
// Both presets agree that October exists and is empty: no "not yet occurred" for one and an empty
|
||
// bucket for the other.
|
||
var today = Assert.Single(plan.Buckets);
|
||
Assert.Equal((Day(2026, 10, 1), now, now), (today.FirstDay, today.From, today.To));
|
||
Assert.Equal(1, plan.PointCount);
|
||
Assert.Equal(last12.To, BucketPlanner.Plan(last12, BucketSize.Month).Buckets[^1].To);
|
||
AssertTiles(monthToDate, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Week_buckets_start_on_Monday_and_the_first_one_starts_with_the_period()
|
||
{
|
||
// 1 September 2026 is a Tuesday.
|
||
var period = Preset(PeriodPreset.MonthToDate);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Week);
|
||
|
||
Assert.Equal(
|
||
[(Day(2026, 9, 1), Day(2026, 9, 7)), (Day(2026, 9, 7), Day(2026, 9, 14)), (Day(2026, 9, 14), Day(2026, 9, 20))],
|
||
plan.Buckets.Select(b => (b.FirstDay, b.EndDay)));
|
||
Assert.Equal(DayOfWeek.Tuesday, plan.Buckets[0].FirstDay.DayOfWeek);
|
||
Assert.All(plan.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, b.FirstDay.DayOfWeek));
|
||
Assert.Equal(September19, plan.Buckets[^1].To);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_range_from_Monday_to_Sunday_is_whole_weeks()
|
||
{
|
||
var period = Custom(Day(2026, 9, 7), Day(2026, 9, 20), At(Berlin, 2026, 10, 1, 12, 0));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Week);
|
||
|
||
Assert.Equal(2, plan.Buckets.Count);
|
||
Assert.All(plan.Buckets, b => Assert.Equal(7, b.EndDay.DayNumber - b.FirstDay.DayNumber));
|
||
Assert.Equal(Utc(2026, 9, 20, 22), plan.Buckets[^1].To);
|
||
}
|
||
|
||
[Fact]
|
||
public void Day_buckets_follow_local_midnight_so_the_spring_DST_day_has_23_hours()
|
||
{
|
||
var march = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 2, 9, 0));
|
||
|
||
var plan = BucketPlanner.Plan(march, BucketSize.Day);
|
||
|
||
Assert.Equal(31, plan.Buckets.Count);
|
||
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 3, 29));
|
||
Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 22)), (dstDay.From, dstDay.To));
|
||
Assert.Equal(TimeSpan.FromHours(23), dstDay.To - dstDay.From);
|
||
Assert.All(plan.Buckets.Where(b => b != dstDay), b => Assert.Equal(TimeSpan.FromHours(24), b.To - b.From));
|
||
AssertTiles(march, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Day_buckets_follow_local_midnight_so_the_autumn_DST_day_has_25_hours()
|
||
{
|
||
var october = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0));
|
||
|
||
var plan = BucketPlanner.Plan(october, BucketSize.Day);
|
||
|
||
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 10, 25));
|
||
Assert.Equal((Utc(2026, 10, 24, 22), Utc(2026, 10, 25, 23)), (dstDay.From, dstDay.To));
|
||
Assert.Equal(TimeSpan.FromHours(25), dstDay.To - dstDay.From);
|
||
}
|
||
|
||
[Fact]
|
||
public void Behind_UTC_month_buckets_start_at_New_York_midnight()
|
||
{
|
||
var period = Custom(Day(2026, 10, 1), Day(2026, 11, 30), zone: NewYork, now: At(NewYork, 2026, 12, 15, 12, 0));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.Equal(
|
||
[(Utc(2026, 10, 1, 4), Utc(2026, 11, 1, 4)), (Utc(2026, 11, 1, 4), Utc(2026, 12, 1, 5))],
|
||
plan.Buckets.Select(b => (b.From, b.To)));
|
||
}
|
||
|
||
[Fact]
|
||
public void A_custom_range_past_now_gets_buckets_only_up_to_now()
|
||
{
|
||
var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
var only = Assert.Single(plan.Buckets);
|
||
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (only.FirstDay, only.EndDay));
|
||
Assert.Equal(September19, only.To);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_period_that_has_not_started_or_has_no_history_has_no_buckets()
|
||
{
|
||
var future = Custom(Day(2027, 1, 1), Day(2027, 3, 31));
|
||
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin);
|
||
|
||
foreach (var period in new[] { future, none })
|
||
{
|
||
foreach (var size in new[] { BucketSize.Auto, BucketSize.Day, BucketSize.Year })
|
||
{
|
||
var plan = BucketPlanner.Plan(period, size);
|
||
Assert.Empty(plan.Buckets);
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(0, plan.PointCount);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(PeriodPreset.MonthToDate, BucketSize.Day, 19)]
|
||
[InlineData(PeriodPreset.LastMonth, BucketSize.Day, 31)]
|
||
[InlineData(PeriodPreset.YearToDate, BucketSize.Month, 9)]
|
||
[InlineData(PeriodPreset.Last12Months, BucketSize.Month, 12)]
|
||
[InlineData(PeriodPreset.Last24Months, BucketSize.Month, 24)]
|
||
[InlineData(PeriodPreset.PreviousYear, BucketSize.Month, 12)]
|
||
public void Auto_picks_days_for_short_ranges_and_months_for_a_year_or_more(PeriodPreset preset, BucketSize expected, int points)
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Auto, plan.Requested);
|
||
Assert.Equal(expected, plan.Size);
|
||
Assert.Equal(points, plan.Buckets.Count);
|
||
Assert.False(plan.Refused);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(62, BucketSize.Day)]
|
||
[InlineData(63, BucketSize.Week)]
|
||
[InlineData(182, BucketSize.Week)]
|
||
[InlineData(183, BucketSize.Month)]
|
||
public void Auto_switches_from_days_to_weeks_after_62_days_and_to_months_after_26_weeks(int days, BucketSize expected)
|
||
{
|
||
var first = Day(2025, 1, 1);
|
||
var period = Custom(first, first.AddDays(days - 1));
|
||
|
||
Assert.Equal(expected, BucketPlanner.Plan(period, BucketSize.Auto).Size);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(1, 1)]
|
||
[InlineData(2, 15)]
|
||
[InlineData(3, 2)]
|
||
[InlineData(6, 15)]
|
||
[InlineData(7, 2)]
|
||
[InlineData(9, 19)]
|
||
[InlineData(12, 31)]
|
||
public void Auto_charts_year_to_date_by_month_on_every_day_of_the_year(int month, int day)
|
||
{
|
||
// Chosen from the named year, not the elapsed part (A-06): the same URL used to render by day until
|
||
// 2 March and by week until 1 July.
|
||
var period = Preset(PeriodPreset.YearToDate, At(Berlin, 2026, month, day, 12, 0));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Month, plan.Size);
|
||
Assert.Equal(month, plan.Buckets.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_charts_month_to_date_by_day_even_on_the_1st()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 9, 1, 8, 0)), BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Day, plan.Size);
|
||
Assert.Single(plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_sizes_a_custom_range_reaching_past_now_by_the_range_asked_for()
|
||
{
|
||
var first = Day(2026, 9, 1);
|
||
var last = Day(2026, 12, 31);
|
||
|
||
var inSeptember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 9, 19, 12, 0)), BucketSize.Auto);
|
||
var inNovember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 11, 20, 12, 0)), BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Week, inSeptember.Size);
|
||
Assert.Equal(BucketSize.Week, inNovember.Size);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_checks_the_point_limit_on_the_buckets_that_exist_up_to_now()
|
||
{
|
||
// The named year has 12 months, over a limit of 9; only the 9 that exist by 19 September are counted.
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.YearToDate), BucketSize.Auto, maxPoints: 9);
|
||
|
||
Assert.Equal(BucketSize.Month, plan.Size);
|
||
Assert.Equal(9, plan.PointCount);
|
||
Assert.False(plan.Refused);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_weighs_months_against_the_point_limit_it_was_given()
|
||
{
|
||
// 501 months since 1985: over the default 400, within a limit of 1,000.
|
||
var since1985 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1985, 1, 1));
|
||
|
||
var generous = BucketPlanner.Plan(since1985, BucketSize.Auto, maxPoints: 1_000);
|
||
var standard = BucketPlanner.Plan(since1985, BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Month, generous.Size);
|
||
Assert.Equal(501, generous.Buckets.Count);
|
||
Assert.Equal(BucketSize.Year, standard.Size);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_charts_all_history_from_a_stray_ancient_reading_by_year_instead_of_refusing()
|
||
{
|
||
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(206, 5, 1));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
|
||
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(BucketSize.Year, plan.Size);
|
||
Assert.Equal(2026 - 1900 + 1, plan.Buckets.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_uses_months_up_to_400_of_them_and_years_beyond()
|
||
{
|
||
// The reference data's oil history starts in 1997: 357 months to September 2026.
|
||
var since1997 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1));
|
||
var since1900 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1900, 1, 1));
|
||
|
||
var monthly = BucketPlanner.Plan(since1997, BucketSize.Auto);
|
||
var yearly = BucketPlanner.Plan(since1900, BucketSize.Auto);
|
||
|
||
Assert.Equal(BucketSize.Month, monthly.Size);
|
||
Assert.Equal(357, monthly.Buckets.Count);
|
||
Assert.Equal(BucketSize.Year, yearly.Size);
|
||
Assert.Equal(127, yearly.Buckets.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_never_goes_finer_than_the_coarsest_resolution_a_series_needs()
|
||
{
|
||
var monthToDate = Preset(PeriodPreset.MonthToDate);
|
||
|
||
// A monthly import in a daily chart would be nothing but unresolved buckets.
|
||
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
|
||
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
|
||
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
|
||
|
||
var month = Assert.Single(monthly.Buckets);
|
||
Assert.Equal((Utc(2026, 8, 31, 22), September19), (month.From, month.To));
|
||
Assert.Equal(BucketSize.Week, weekly.Size);
|
||
Assert.Equal(BucketSize.Year, coarse.Size);
|
||
Assert.Equal([Day(2025, 10, 1), Day(2026, 1, 1)], coarse.Buckets.Select(b => b.FirstDay));
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_keeps_the_length_based_default_when_the_data_is_finer()
|
||
{
|
||
// Hourly data does not turn a year into 365 bars; the user can still ask for days explicitly.
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Hour);
|
||
|
||
Assert.Equal(BucketSize.Month, plan.Size);
|
||
}
|
||
|
||
[Fact]
|
||
public void Auto_coarsens_until_the_plan_fits_a_smaller_point_limit()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, maxPoints: 10);
|
||
|
||
Assert.Equal(BucketSize.Week, plan.Size);
|
||
Assert.Equal(3, plan.Buckets.Count);
|
||
Assert.False(plan.Refused);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_explicit_day_bucket_over_400_points_is_refused_with_weeks_suggested()
|
||
{
|
||
var period = Preset(PeriodPreset.Last24Months);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Day);
|
||
|
||
Assert.True(plan.Refused);
|
||
Assert.Empty(plan.Buckets);
|
||
Assert.Equal(BucketSize.Day, plan.Size);
|
||
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2024, 10, 1).DayNumber + 1, plan.PointCount);
|
||
Assert.Equal(BucketSize.Week, plan.Suggested);
|
||
Assert.False(BucketPlanner.Plan(period, BucketSize.Week).Refused);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_refusal_suggests_nothing_finer_than_the_data_resolves()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last24Months), BucketSize.Day, ResolutionClass.Month);
|
||
|
||
Assert.True(plan.Refused);
|
||
Assert.Equal(BucketSize.Month, plan.Suggested);
|
||
}
|
||
|
||
[Fact]
|
||
public void A_refusal_over_centuries_suggests_years()
|
||
{
|
||
var period = Custom(Day(1950, 1, 1), Day(2025, 12, 31));
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Month);
|
||
|
||
Assert.True(plan.Refused);
|
||
Assert.Equal(76 * 12, plan.PointCount);
|
||
Assert.Equal(BucketSize.Year, plan.Suggested);
|
||
}
|
||
|
||
[Fact]
|
||
public void An_explicit_size_within_the_limit_is_honoured_even_finer_than_the_data()
|
||
{
|
||
var period = Preset(PeriodPreset.Last12Months);
|
||
|
||
var plan = BucketPlanner.Plan(period, BucketSize.Day, ResolutionClass.Month);
|
||
|
||
Assert.False(plan.Refused);
|
||
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2025, 10, 1).DayNumber + 1, plan.Buckets.Count);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
|
||
[Fact]
|
||
public void Every_size_counts_its_buckets_without_building_them()
|
||
{
|
||
var periods = new[]
|
||
{
|
||
Preset(PeriodPreset.MonthToDate),
|
||
Preset(PeriodPreset.Last24Months),
|
||
Preset(PeriodPreset.PreviousYear),
|
||
Custom(Day(2023, 12, 31), Day(2025, 1, 1)),
|
||
Preset(PeriodPreset.YearToDate, At(NewYork, 2026, 3, 8, 12, 0), NewYork),
|
||
};
|
||
|
||
foreach (var period in periods)
|
||
{
|
||
foreach (var size in new[] { BucketSize.Day, BucketSize.Week, BucketSize.Month, BucketSize.Year })
|
||
{
|
||
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
|
||
Assert.Equal(BucketPlanner.CountBuckets(period, size), plan.Buckets.Count);
|
||
AssertTiles(period, plan.Buckets);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void The_current_month_bucket_is_cut_short_at_now_and_names_the_whole_month_to_drill_into()
|
||
{
|
||
// Drilling into "1 – 19 Sep" as a custom range would compare with 13 – 31 August; the whole month
|
||
// compares with 1 – 19 August, like month to date (D-51).
|
||
foreach (var preset in new[] { PeriodPreset.MonthToDate, PeriodPreset.YearToDate, PeriodPreset.Last12Months })
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Month);
|
||
|
||
var september = plan.Buckets[^1];
|
||
Assert.Equal((Day(2026, 9, 20), Day(2026, 10, 1)), (september.EndDay, september.NominalEndDay));
|
||
Assert.True(september.IsCutShort);
|
||
Assert.All(plan.Buckets.SkipLast(1), b => Assert.False(b.IsCutShort));
|
||
Assert.All(plan.Buckets.SkipLast(1), b => Assert.Null(b.NominalEndDay));
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void A_week_cut_at_now_names_its_Sunday_and_a_day_is_never_cut_short()
|
||
{
|
||
var weeks = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Week);
|
||
var days = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day);
|
||
|
||
Assert.Equal(Day(2026, 9, 21), weeks.Buckets[^1].NominalEndDay);
|
||
Assert.All(days.Buckets, b => Assert.False(b.IsCutShort));
|
||
}
|
||
|
||
[Fact]
|
||
public void The_whole_unit_of_a_cut_bucket_never_reaches_past_the_range_that_was_asked_for()
|
||
{
|
||
var reachingPastNow = BucketPlanner.Plan(Custom(Day(2026, 9, 1), Day(2026, 9, 25)), BucketSize.Month);
|
||
var complete = BucketPlanner.Plan(Custom(Day(2026, 8, 10), Day(2026, 9, 15), At(Berlin, 2026, 10, 1, 12, 0)), BucketSize.Month);
|
||
|
||
Assert.Equal(Day(2026, 9, 26), Assert.Single(reachingPastNow.Buckets).NominalEndDay);
|
||
|
||
// A range that ends mid-month by request is not cut short: its last bucket is all it names.
|
||
Assert.All(complete.Buckets, b => Assert.False(b.IsCutShort));
|
||
Assert.Equal(Day(2026, 9, 16), complete.Buckets[^1].EndDay);
|
||
}
|
||
|
||
[Fact]
|
||
public void At_midnight_the_empty_current_month_still_names_the_whole_month()
|
||
{
|
||
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1)), BucketSize.Month);
|
||
|
||
Assert.Equal((Day(2026, 10, 2), Day(2026, 11, 1)), (plan.Buckets[^1].EndDay, plan.Buckets[^1].NominalEndDay));
|
||
}
|
||
|
||
[Fact]
|
||
public void The_point_limit_must_allow_at_least_one_point()
|
||
{
|
||
Assert.Throws<ArgumentOutOfRangeException>(() => BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day, maxPoints: 0));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(ResolutionClass.Hour, BucketSize.Day)]
|
||
[InlineData(ResolutionClass.Day, BucketSize.Day)]
|
||
[InlineData(ResolutionClass.Week, BucketSize.Week)]
|
||
[InlineData(ResolutionClass.Month, BucketSize.Month)]
|
||
[InlineData(ResolutionClass.Coarse, BucketSize.Year)]
|
||
public void Each_resolution_class_maps_to_the_finest_bucket_it_can_fill(ResolutionClass resolution, BucketSize expected)
|
||
{
|
||
Assert.Equal(expected, BucketPlanner.MinimumSizeFor(resolution));
|
||
}
|
||
}
|