From f7de985dbacc2b3987fc6f142dfbfcd80468b3f9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:00:58 -0400 Subject: [PATCH 01/33] Time range model: presets, calendar periods, typed-range parser and shared fixture (#5562) --- Darling/Darling.Tests/Darling.Tests.csproj | 2 + .../Fixtures/time-range-cases.json | 1113 +++++++++++++++++ Darling/Darling.Tests/TimeRangeTests.cs | 284 +++++ Lite.Tests/Lite.Tests.csproj | 3 + .../TimeRange/ResolvedTimeRange.cs | 109 ++ .../TimeRange/TimeRangeParser.cs | 617 +++++++++ .../TimeRange/TimeRangePresets.cs | 159 +++ .../TimeRange/TimeRangeSpec.cs | 461 +++++++ 8 files changed, 2748 insertions(+) create mode 100644 Darling/Darling.Tests/Fixtures/time-range-cases.json create mode 100644 Darling/Darling.Tests/TimeRangeTests.cs create mode 100644 PerformanceMonitor.Ui/TimeRange/ResolvedTimeRange.cs create mode 100644 PerformanceMonitor.Ui/TimeRange/TimeRangeParser.cs create mode 100644 PerformanceMonitor.Ui/TimeRange/TimeRangePresets.cs create mode 100644 PerformanceMonitor.Ui/TimeRange/TimeRangeSpec.cs diff --git a/Darling/Darling.Tests/Darling.Tests.csproj b/Darling/Darling.Tests/Darling.Tests.csproj index 3612ad7360..f897f59612 100644 --- a/Darling/Darling.Tests/Darling.Tests.csproj +++ b/Darling/Darling.Tests/Darling.Tests.csproj @@ -50,6 +50,8 @@ + + diff --git a/Darling/Darling.Tests/Fixtures/time-range-cases.json b/Darling/Darling.Tests/Fixtures/time-range-cases.json new file mode 100644 index 0000000000..1b51a1e519 --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/time-range-cases.json @@ -0,0 +1,1113 @@ +{ + "_doc": [ + "Shared cases for the time range grammar (#5562). The C# parser (PerformanceMonitor.Ui TimeRangeParser) and the web module (time-range.js) must both produce every row here.", + "Zone ids are IANA names (America/New_York, UTC, Asia/Kolkata, Australia/Sydney): .NET resolves them through ICU and Node through Intl. Do not use Windows ids.", + "All instants are ISO 8601 UTC with a trailing Z. 'now' is the clock the case is resolved at; nothing reads the machine's clock.", + "'start' and 'end' are the resolved instants (end is now for a live range). 'live' is true when the end slides with now. 'echo' is the exact label text the picker shows. 'error' is a stable code; the message text is not compared.", + "Typed wall times use the earliest instant for a start and the latest for an end (a repeated hour widens; a skipped time maps to the change instant). Nm/Nh/Nd are real elapsed time. Calendar periods use wall-clock midnights in the zone and weeks start Monday. 1mo is 30 real days.", + "Echo format: ' - ()'. Length is Nd (floored) for a day or more, else 'Hh Mm' with zero parts dropped. A bound is 'Mon D, h:mm am|pm' (English month abbreviations, lower-case am/pm), with ', YYYY' after the day when either end is not in now's year, ':ss' for typed/unix ranges when the seconds are not zero, and ' +hh:mm' appended to a bound whose wall time happens twice. Zone is 'UTC-04:00' or 'UTC-05:00 to UTC-04:00' when the ends differ." + ], + "minimumSpanMinutes": 5, + "cases": [ + { + "name": "relative 45m", + "input": "45m", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T10:16:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "45m Oct 8, 6:16 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 12 hours", + "input": "12 hours", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T23:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "12h Oct 7, 7:01 pm - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 10d", + "input": "10d", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-28T11:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "10d Sep 28, 7:01 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 2 weeks", + "input": "2 weeks", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-24T11:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "14d Sep 24, 7:01 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative last 3 days", + "input": "last 3 days", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-05T11:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "3d Oct 5, 7:01 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative past 90 min", + "input": "Past 90 Min", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T09:31:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "1h 30m Oct 8, 5:31 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 1.5 hours", + "input": "1.5 hours", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T09:31:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "1h 30m Oct 8, 5:31 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 1mo is 30 real days", + "input": "1mo", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-08T11:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "30d Sep 8, 7:01 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 1 month typed in full", + "input": "1 month", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-08T11:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "30d Sep 8, 7:01 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative spaces and case", + "input": " 12 HOURS ", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T23:01:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "12h Oct 7, 7:01 pm - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "relative 5m is the floor", + "input": "5m", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T10:56:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "5m Oct 8, 6:56 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "last month", + "input": "last month", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-01T04:00:00Z", + "end": "2026-10-01T04:00:00Z", + "live": false, + "echo": "30d Sep 1, 12:00 am - Oct 1, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "previous month typed out", + "input": "Previous Month", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-01T04:00:00Z", + "end": "2026-10-01T04:00:00Z", + "live": false, + "echo": "30d Sep 1, 12:00 am - Oct 1, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "yesterday", + "input": "yesterday", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T04:00:00Z", + "end": "2026-10-08T04:00:00Z", + "live": false, + "echo": "1d Oct 7, 12:00 am - Oct 8, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "today", + "input": "today", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T04:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "7h 1m Oct 8, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "week to date starts Monday", + "input": "week to date", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-05T04:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "3d Oct 5, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "this week", + "input": "this week", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-05T04:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "3d Oct 5, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "last week", + "input": "last week", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-09-28T04:00:00Z", + "end": "2026-10-05T04:00:00Z", + "live": false, + "echo": "7d Sep 28, 12:00 am - Oct 5, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "month to date", + "input": "mtd", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "7d Oct 1, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "year to date spans the winter offset", + "input": "year to date", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-01-01T05:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "280d Jan 1, 12:00 am - Oct 8, 7:01 am (UTC-05:00 to UTC-04:00)", + "error": null + }, + { + "name": "last year", + "input": "last year", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2025-01-01T05:00:00Z", + "end": "2026-01-01T05:00:00Z", + "live": false, + "echo": "365d Jan 1, 2025, 12:00 am - Jan 1, 2026, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "sunday belongs to the week that began Monday", + "input": "this week", + "now": "2026-10-12T03:30:00Z", + "zone": "America/New_York", + "start": "2026-10-05T04:00:00Z", + "end": "2026-10-12T03:30:00Z", + "live": true, + "echo": "6d Oct 5, 12:00 am - Oct 11, 11:30 pm (UTC-04:00)", + "error": null + }, + { + "name": "monday morning: this week has only just begun", + "input": "this week", + "now": "2026-10-12T14:00:00Z", + "zone": "America/New_York", + "start": "2026-10-12T04:00:00Z", + "end": "2026-10-12T14:00:00Z", + "live": true, + "echo": "10h Oct 12, 12:00 am - Oct 12, 10:00 am (UTC-04:00)", + "error": null + }, + { + "name": "one date is the whole day", + "input": "Oct 1", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-02T04:00:00Z", + "live": false, + "echo": "1d Oct 1, 12:00 am - Oct 2, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "one numeric date is the whole day", + "input": "10/1", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-02T04:00:00Z", + "live": false, + "echo": "1d Oct 1, 12:00 am - Oct 2, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "end date is included", + "input": "Oct 1 - Oct 2", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-03T04:00:00Z", + "live": false, + "echo": "2d Oct 1, 12:00 am - Oct 3, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "numeric dates", + "input": "10/1 - 10/2", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-03T04:00:00Z", + "live": false, + "echo": "2d Oct 1, 12:00 am - Oct 3, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "numeric dates with no spaces", + "input": "10/1-10/2", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-03T04:00:00Z", + "live": false, + "echo": "2d Oct 1, 12:00 am - Oct 3, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "to and an en dash", + "input": "Oct 1 – Oct 2", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-03T04:00:00Z", + "live": false, + "echo": "2d Oct 1, 12:00 am - Oct 3, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "to as a word", + "input": "october 1st to october 2nd", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-03T04:00:00Z", + "live": false, + "echo": "2d Oct 1, 12:00 am - Oct 3, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "from ... to ...", + "input": "from 10/1 to 10/3", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-04T04:00:00Z", + "live": false, + "echo": "3d Oct 1, 12:00 am - Oct 4, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "iso date alone", + "input": "2026-10-01", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-02T04:00:00Z", + "live": false, + "echo": "1d Oct 1, 12:00 am - Oct 2, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "iso date and time", + "input": "2026-10-01 13:00 - 2026-10-01 15:00", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T17:00:00Z", + "end": "2026-10-01T19:00:00Z", + "live": false, + "echo": "2h Oct 1, 1:00 pm - Oct 1, 3:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "explicit years", + "input": "12/31/2025 - 1/2/26", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2025-12-31T05:00:00Z", + "end": "2026-01-03T05:00:00Z", + "live": false, + "echo": "3d Dec 31, 2025, 12:00 am - Jan 3, 2026, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "day first", + "input": "1 Oct 2025", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2025-10-01T04:00:00Z", + "end": "2025-10-02T04:00:00Z", + "live": false, + "echo": "1d Oct 1, 2025, 12:00 am - Oct 2, 2025, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "a date with a time, same day end", + "input": "Oct 2 12pm - 3pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-02T16:00:00Z", + "end": "2026-10-02T19:00:00Z", + "live": false, + "echo": "3h Oct 2, 12:00 pm - Oct 2, 3:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "date and time on both sides", + "input": "Oct 2 9:30 am to Oct 3 5:15 pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-02T13:30:00Z", + "end": "2026-10-03T21:15:00Z", + "live": false, + "echo": "1d Oct 2, 9:30 am - Oct 3, 5:15 pm (UTC-04:00)", + "error": null + }, + { + "name": "date only start, time end", + "input": "Oct 1 - 3pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-01T19:00:00Z", + "live": false, + "echo": "15h Oct 1, 12:00 am - Oct 1, 3:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "date year from the latest not in the future (earlier this year)", + "input": "Oct 1", + "now": "2026-09-15T16:00:00Z", + "zone": "America/New_York", + "start": "2025-10-01T04:00:00Z", + "end": "2025-10-02T04:00:00Z", + "live": false, + "echo": "1d Oct 1, 2025, 12:00 am - Oct 2, 2025, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "same date today counts as not in the future", + "input": "Oct 8", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T04:00:00Z", + "end": "2026-10-09T04:00:00Z", + "live": false, + "echo": "1d Oct 8, 12:00 am - Oct 9, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "range across new year takes the year before", + "input": "Dec 30 - Jan 2", + "now": "2026-01-15T17:00:00Z", + "zone": "America/New_York", + "start": "2025-12-30T05:00:00Z", + "end": "2026-01-03T05:00:00Z", + "live": false, + "echo": "4d Dec 30, 2025, 12:00 am - Jan 3, 2026, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "leap day goes back to the last leap year", + "input": "Feb 29", + "now": "2026-03-15T16:00:00Z", + "zone": "America/New_York", + "start": "2024-02-29T05:00:00Z", + "end": "2024-03-01T05:00:00Z", + "live": false, + "echo": "1d Feb 29, 2024, 12:00 am - Mar 1, 2024, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "times only, today", + "input": "1:00 am - 7:00 am", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T05:00:00Z", + "end": "2026-10-08T11:00:00Z", + "live": false, + "echo": "6h Oct 8, 1:00 am - Oct 8, 7:00 am (UTC-04:00)", + "error": null + }, + { + "name": "times only, 24 hour, not yet today so yesterday", + "input": "13:00 - 15:00", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T17:00:00Z", + "end": "2026-10-07T19:00:00Z", + "live": false, + "echo": "2h Oct 7, 1:00 pm - Oct 7, 3:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "times only crossing midnight", + "input": "22:00 - 02:00", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T02:00:00Z", + "end": "2026-10-08T06:00:00Z", + "live": false, + "echo": "4h Oct 7, 10:00 pm - Oct 8, 2:00 am (UTC-04:00)", + "error": null + }, + { + "name": "noon to midnight", + "input": "noon - midnight", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T16:00:00Z", + "end": "2026-10-08T04:00:00Z", + "live": false, + "echo": "12h Oct 7, 12:00 pm - Oct 8, 12:00 am (UTC-04:00)", + "error": null + }, + { + "name": "12 hour edge cases", + "input": "12am - 12pm", + "now": "2026-10-08T18:00:00Z", + "zone": "America/New_York", + "start": "2026-10-08T04:00:00Z", + "end": "2026-10-08T16:00:00Z", + "live": false, + "echo": "12h Oct 8, 12:00 am - Oct 8, 12:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "time start, dated end", + "input": "9am - Oct 7 5pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T13:00:00Z", + "end": "2026-10-07T21:00:00Z", + "live": false, + "echo": "8h Oct 7, 9:00 am - Oct 7, 5:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "since a date", + "input": "since 10/1", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "7d Oct 1, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "since a date and time", + "input": "since Oct 2 12pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-02T16:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "5d Oct 2, 12:00 pm - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "to now", + "input": "Oct 2 12pm to now", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-02T16:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "5d Oct 2, 12:00 pm - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "dash now", + "input": "10/1 - now", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "7d Oct 1, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "since a time is the latest such time", + "input": "since 9am", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-07T13:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "22h 1m Oct 7, 9:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "since a time already passed today", + "input": "since 5:00", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-08T09:00:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "2h 1m Oct 8, 5:00 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "unix seconds", + "input": "1790852543 - 1791457343", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T11:02:23Z", + "end": "2026-10-08T11:02:23Z", + "live": false, + "echo": "7d Oct 1, 7:02:23 am - Oct 8, 7:02:23 am (UTC-04:00)", + "error": null + }, + { + "name": "unix milliseconds", + "input": "1790852543000 - 1791457343000", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T11:02:23Z", + "end": "2026-10-08T11:02:23Z", + "live": false, + "echo": "7d Oct 1, 7:02:23 am - Oct 8, 7:02:23 am (UTC-04:00)", + "error": null + }, + { + "name": "unix since", + "input": "since 1790852543", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": "2026-10-01T11:02:23Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "6d Oct 1, 7:02 am - Oct 8, 7:01 am (UTC-04:00)", + "error": null + }, + { + "name": "UTC yesterday", + "input": "yesterday", + "now": "2026-10-08T11:01:00Z", + "zone": "UTC", + "start": "2026-10-07T00:00:00Z", + "end": "2026-10-08T00:00:00Z", + "live": false, + "echo": "1d Oct 7, 12:00 am - Oct 8, 12:00 am (UTC+00:00)", + "error": null + }, + { + "name": "UTC typed times", + "input": "1:00 am - 7:00 am", + "now": "2026-10-08T07:30:00Z", + "zone": "UTC", + "start": "2026-10-08T01:00:00Z", + "end": "2026-10-08T07:00:00Z", + "live": false, + "echo": "6h Oct 8, 1:00 am - Oct 8, 7:00 am (UTC+00:00)", + "error": null + }, + { + "name": "India half-hour zone, today", + "input": "today", + "now": "2026-10-08T11:01:00Z", + "zone": "Asia/Kolkata", + "start": "2026-10-07T18:30:00Z", + "end": "2026-10-08T11:01:00Z", + "live": true, + "echo": "16h 31m Oct 8, 12:00 am - Oct 8, 4:31 pm (UTC+05:30)", + "error": null + }, + { + "name": "India typed times", + "input": "13:00 - 15:00", + "now": "2026-10-08T11:01:00Z", + "zone": "Asia/Kolkata", + "start": "2026-10-08T07:30:00Z", + "end": "2026-10-08T09:30:00Z", + "live": false, + "echo": "2h Oct 8, 1:00 pm - Oct 8, 3:00 pm (UTC+05:30)", + "error": null + }, + { + "name": "Sydney last month (southern summer time)", + "input": "last month", + "now": "2026-10-08T11:01:00Z", + "zone": "Australia/Sydney", + "start": "2026-08-31T14:00:00Z", + "end": "2026-09-30T14:00:00Z", + "live": false, + "echo": "30d Sep 1, 12:00 am - Oct 1, 12:00 am (UTC+10:00)", + "error": null + }, + { + "name": "spring: the short day is 23 hours", + "input": "Mar 8", + "now": "2026-03-09T16:00:00Z", + "zone": "America/New_York", + "start": "2026-03-08T05:00:00Z", + "end": "2026-03-09T04:00:00Z", + "live": false, + "echo": "23h Mar 8, 12:00 am - Mar 9, 12:00 am (UTC-05:00 to UTC-04:00)", + "error": null + }, + { + "name": "spring: yesterday is 23 hours", + "input": "yesterday", + "now": "2026-03-09T16:00:00Z", + "zone": "America/New_York", + "start": "2026-03-08T05:00:00Z", + "end": "2026-03-09T04:00:00Z", + "live": false, + "echo": "23h Mar 8, 12:00 am - Mar 9, 12:00 am (UTC-05:00 to UTC-04:00)", + "error": null + }, + { + "name": "spring: 1d is 24 real hours", + "input": "1d", + "now": "2026-03-09T16:00:00Z", + "zone": "America/New_York", + "start": "2026-03-08T16:00:00Z", + "end": "2026-03-09T16:00:00Z", + "live": true, + "echo": "1d Mar 8, 12:00 pm - Mar 9, 12:00 pm (UTC-04:00)", + "error": null + }, + { + "name": "spring: last week ends at Monday midnight", + "input": "last week", + "now": "2026-03-09T16:00:00Z", + "zone": "America/New_York", + "start": "2026-03-02T05:00:00Z", + "end": "2026-03-09T04:00:00Z", + "live": false, + "echo": "6d Mar 2, 12:00 am - Mar 9, 12:00 am (UTC-05:00 to UTC-04:00)", + "error": null + }, + { + "name": "spring: a skipped start maps to the change instant", + "input": "Mar 8 2:30 am - Mar 8 4:00 am", + "now": "2026-03-09T16:00:00Z", + "zone": "America/New_York", + "start": "2026-03-08T07:00:00Z", + "end": "2026-03-08T08:00:00Z", + "live": false, + "echo": "1h Mar 8, 3:00 am - Mar 8, 4:00 am (UTC-04:00)", + "error": null + }, + { + "name": "spring: times across the change", + "input": "Mar 8 1:00 am - Mar 8 4:00 am", + "now": "2026-03-09T16:00:00Z", + "zone": "America/New_York", + "start": "2026-03-08T06:00:00Z", + "end": "2026-03-08T08:00:00Z", + "live": false, + "echo": "2h Mar 8, 1:00 am - Mar 8, 4:00 am (UTC-05:00 to UTC-04:00)", + "error": null + }, + { + "name": "fall: the long day is 25 hours", + "input": "Nov 1", + "now": "2026-11-02T17:00:00Z", + "zone": "America/New_York", + "start": "2026-11-01T04:00:00Z", + "end": "2026-11-02T05:00:00Z", + "live": false, + "echo": "1d Nov 1, 12:00 am - Nov 2, 12:00 am (UTC-04:00 to UTC-05:00)", + "error": null + }, + { + "name": "fall: yesterday is 25 hours", + "input": "yesterday", + "now": "2026-11-02T17:00:00Z", + "zone": "America/New_York", + "start": "2026-11-01T04:00:00Z", + "end": "2026-11-02T05:00:00Z", + "live": false, + "echo": "1d Nov 1, 12:00 am - Nov 2, 12:00 am (UTC-04:00 to UTC-05:00)", + "error": null + }, + { + "name": "fall: a repeated hour widens both ends", + "input": "Nov 1 1:00 am - Nov 1 1:45 am", + "now": "2026-11-02T17:00:00Z", + "zone": "America/New_York", + "start": "2026-11-01T05:00:00Z", + "end": "2026-11-01T06:45:00Z", + "live": false, + "echo": "1h 45m Nov 1, 1:00 am -04:00 - Nov 1, 1:45 am -05:00 (UTC-04:00 to UTC-05:00)", + "error": null + }, + { + "name": "fall: a repeated start with a plain end", + "input": "1:30 am - 3:00 am", + "now": "2026-11-01T18:00:00Z", + "zone": "America/New_York", + "start": "2026-11-01T05:30:00Z", + "end": "2026-11-01T08:00:00Z", + "live": false, + "echo": "2h 30m Nov 1, 1:30 am -04:00 - Nov 1, 3:00 am (UTC-04:00 to UTC-05:00)", + "error": null + }, + { + "name": "fall: times only crossing midnight", + "input": "22:00 - 02:00", + "now": "2026-11-02T17:00:00Z", + "zone": "America/New_York", + "start": "2026-11-02T03:00:00Z", + "end": "2026-11-02T07:00:00Z", + "live": false, + "echo": "4h Nov 1, 10:00 pm - Nov 2, 2:00 am (UTC-05:00)", + "error": null + }, + { + "name": "fall: 1d is 24 real hours", + "input": "1d", + "now": "2026-11-02T17:00:00Z", + "zone": "America/New_York", + "start": "2026-11-01T17:00:00Z", + "end": "2026-11-02T17:00:00Z", + "live": true, + "echo": "1d Nov 1, 12:00 pm - Nov 2, 12:00 pm (UTC-05:00)", + "error": null + }, + { + "name": "last month in January is December", + "input": "last month", + "now": "2026-01-15T17:00:00Z", + "zone": "America/New_York", + "start": "2025-12-01T05:00:00Z", + "end": "2026-01-01T05:00:00Z", + "live": false, + "echo": "31d Dec 1, 2025, 12:00 am - Jan 1, 2026, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "last month in March is a 28 day February", + "input": "last month", + "now": "2026-03-15T16:00:00Z", + "zone": "America/New_York", + "start": "2026-02-01T05:00:00Z", + "end": "2026-03-01T05:00:00Z", + "live": false, + "echo": "28d Feb 1, 12:00 am - Mar 1, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "last month in March of a leap year", + "input": "last month", + "now": "2024-03-15T16:00:00Z", + "zone": "America/New_York", + "start": "2024-02-01T05:00:00Z", + "end": "2024-03-01T05:00:00Z", + "live": false, + "echo": "29d Feb 1, 12:00 am - Mar 1, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "last year in January", + "input": "last year", + "now": "2026-01-15T17:00:00Z", + "zone": "America/New_York", + "start": "2025-01-01T05:00:00Z", + "end": "2026-01-01T05:00:00Z", + "live": false, + "echo": "365d Jan 1, 2025, 12:00 am - Jan 1, 2026, 12:00 am (UTC-05:00)", + "error": null + }, + { + "name": "year to date on January 1", + "input": "ytd", + "now": "2026-01-01T17:00:00Z", + "zone": "America/New_York", + "start": "2026-01-01T05:00:00Z", + "end": "2026-01-01T17:00:00Z", + "live": true, + "echo": "12h Jan 1, 12:00 am - Jan 1, 12:00 pm (UTC-05:00)", + "error": null + }, + { + "name": "month to date on the 31st", + "input": "month to date", + "now": "2026-10-31T23:30:00Z", + "zone": "America/New_York", + "start": "2026-10-01T04:00:00Z", + "end": "2026-10-31T23:30:00Z", + "live": true, + "echo": "30d Oct 1, 12:00 am - Oct 31, 7:30 pm (UTC-04:00)", + "error": null + }, + { + "name": "empty", + "input": "", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "empty" + }, + { + "name": "blank", + "input": " ", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "empty" + }, + { + "name": "nonsense", + "input": "banana", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "unrecognized" + }, + { + "name": "years are not a unit", + "input": "5 years", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "unrecognized" + }, + { + "name": "shorter than the minimum", + "input": "3m", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_short" + }, + { + "name": "zero", + "input": "0h", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_short" + }, + { + "name": "a typed range under the minimum", + "input": "10:00 - 10:03", + "now": "2026-10-08T20:00:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_short" + }, + { + "name": "today just after midnight", + "input": "today", + "now": "2026-10-08T04:02:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_short" + }, + { + "name": "end before start", + "input": "Oct 2 - Oct 1", + "now": "2026-10-08T20:00:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "end_before_start" + }, + { + "name": "a single time needs an end", + "input": "13:00", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "needs_end" + }, + { + "name": "a date and time needs an end", + "input": "Oct 1 12pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "needs_end" + }, + { + "name": "now alone", + "input": "now", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "needs_end" + }, + { + "name": "no such day", + "input": "Feb 30", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "bad_date" + }, + { + "name": "no such month", + "input": "13/1", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "bad_date" + }, + { + "name": "a bad hour", + "input": "25:00 - 26:00", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "bad_time" + }, + { + "name": "12 hour clock has no 13", + "input": "13pm - 2pm", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "bad_time" + }, + { + "name": "since a start in the future", + "input": "since Dec 25 2030", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "start_in_future" + }, + { + "name": "now cannot start", + "input": "now - Oct 1", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "bad_range" + }, + { + "name": "two separators", + "input": "Oct 1 - Oct 2 - Oct 3", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "bad_range" + }, + { + "name": "too far back", + "input": "99999999 days", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_far_back" + } + ] +} diff --git a/Darling/Darling.Tests/TimeRangeTests.cs b/Darling/Darling.Tests/TimeRangeTests.cs new file mode 100644 index 0000000000..40a3c372d0 --- /dev/null +++ b/Darling/Darling.Tests/TimeRangeTests.cs @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Ui; +using Xunit; + +/* This file is compiled into Darling.Tests and Lite.Tests (a linked Compile item), so both test assemblies pin the + shared picker model; the namespace is the same in each. */ +namespace Darling.Tests; + +/// +/// #5562: the shared time range grammar. Every row of Fixtures/time-range-cases.json must parse to the +/// stated instants, liveness and echo text (or the stated error code); the web module runs the same file. +/// +public sealed class TimeRangeFixtureTests +{ + private static readonly string FixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "time-range-cases.json"); + + public static IEnumerable Cases() + { + using var doc = JsonDocument.Parse(File.ReadAllText(FixturePath)); + foreach (var c in doc.RootElement.GetProperty("cases").EnumerateArray()) + { + yield return new object[] { c.GetProperty("name").GetString()! + " [" + c.GetProperty("input").GetString() + "]" , c.GetRawText() }; + } + } + + [Fact] + public void Fixture_UsesZoneIdsThisRuntimeResolves() + { + using var doc = JsonDocument.Parse(File.ReadAllText(FixturePath)); + var zones = doc.RootElement.GetProperty("cases").EnumerateArray().Select(c => c.GetProperty("zone").GetString()!).Distinct().ToList(); + Assert.NotEmpty(zones); + foreach (var id in zones) + { + Assert.NotNull(TimeZoneInfo.FindSystemTimeZoneById(id)); + } + } + + [Theory] + [MemberData(nameof(Cases))] + public void Parse_MatchesTheSharedFixture(string label, string caseJson) + { + Assert.False(string.IsNullOrEmpty(label)); + using var doc = JsonDocument.Parse(caseJson); + var c = doc.RootElement; + var input = c.GetProperty("input").GetString(); + var now = ParseZ(c.GetProperty("now").GetString()!); + var zone = TimeZoneInfo.FindSystemTimeZoneById(c.GetProperty("zone").GetString()!); + + var result = TimeRangeParser.Parse(input, now, zone); + + var expectedError = c.GetProperty("error"); + if (expectedError.ValueKind == JsonValueKind.String) + { + Assert.False(result.Ok, "expected an error but got " + result.Echo); + Assert.Equal(expectedError.GetString(), result.ErrorCode); + Assert.False(string.IsNullOrWhiteSpace(result.Error)); + Assert.Equal(string.Empty, result.Echo); + return; + } + + Assert.True(result.Ok, result.ErrorCode + ": " + result.Error); + Assert.Equal(c.GetProperty("start").GetString(), Z(result.Range!.StartUtc)); + Assert.Equal(c.GetProperty("end").GetString(), Z(result.Range!.EndUtc)); + Assert.Equal(c.GetProperty("live").GetBoolean(), result.Range.IsLive); + Assert.Equal(c.GetProperty("echo").GetString(), result.Echo); + } + + internal static DateTime ParseZ(string iso) + => DateTime.SpecifyKind(DateTime.ParseExact(iso, "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.None), DateTimeKind.Unspecified); + + internal static string Z(DateTime naiveUtc) + => naiveUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); +} + +/// #5562: the preset lists, calendar periods, ids, legacy hour mapping and the notes beside the picker. +public sealed class TimeRangeModelTests +{ + private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + private static readonly DateTime Now = new(2026, 10, 8, 11, 1, 0, DateTimeKind.Unspecified); + + [Fact] + public void RollingPresets_AreTheNineShortOnesInOrder() + { + Assert.Equal(new[] { "5m", "15m", "30m", "1h", "4h", "1d", "2d", "1w", "1mo" }, TimeRangePresets.Rolling.Select(p => p.Id)); + } + + [Fact] + public void CalendarPeriods_AreTheEightInOrder() + { + Assert.Equal( + new[] { "Today", "Yesterday", "Week to Date", "Previous Week", "Month to Date", "Previous Month", "Year to Date", "Previous Year" }, + TimeRangePresets.CalendarPeriods.Select(p => p.Name)); + } + + [Fact] + public void CalendarPeriods_ReportTheirCurrentLength() + { + // Thursday 7:01 am in New York: Week to Date is Monday 00:00 to now. + Assert.Equal("3d", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.WeekToDate), Now, NewYork)); + Assert.Equal("7h 1m", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.Today), Now, NewYork)); + Assert.Equal("1d", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.Yesterday), Now, NewYork)); + Assert.Equal("7d", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.PreviousWeek), Now, NewYork)); + Assert.Equal("30d", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.PreviousMonth), Now, NewYork)); + Assert.Equal("365d", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.PreviousYear), Now, NewYork)); + // Just after midnight Today is under the minimum, so it has no length to show. + Assert.Null(TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.Today), new DateTime(2026, 10, 8, 4, 2, 0), NewYork)); + } + + [Fact] + public void ShortDaysOnTheChangeDays_ReadAs23And25Hours() + { + var spec = TimeRangeSpec.ForPeriod(CalendarPeriod.Yesterday); + Assert.Equal("23h", TimeRangePresets.CurrentLength(spec, new DateTime(2026, 3, 9, 16, 0, 0), NewYork)); + Assert.Equal("1d", TimeRangePresets.CurrentLength(spec, new DateTime(2026, 11, 2, 17, 0, 0), NewYork)); + Assert.True(spec.TryResolve(new DateTime(2026, 11, 2, 17, 0, 0), NewYork, out var longDay, out _)); + Assert.Equal(TimeSpan.FromHours(25), longDay!.Span); + } + + [Fact] + public void ConsecutivePeriods_TileWithNoGap() + { + var now = new DateTime(2026, 3, 9, 16, 0, 0); + Assert.True(TimeRangeSpec.ForPeriod(CalendarPeriod.PreviousWeek).TryResolve(now, NewYork, out var lastWeek, out _)); + Assert.True(TimeRangeSpec.ForPeriod(CalendarPeriod.WeekToDate).TryResolve(now, NewYork, out var thisWeek, out _)); + Assert.Equal(lastWeek!.EndUtc, thisWeek!.StartUtc); + } + + [Theory] + [InlineData(1, "1h")] + [InlineData(4, "4h")] + [InlineData(12, "12h")] + [InlineData(24, "1d")] + [InlineData(168, "1w")] + [InlineData(48, "2d")] + [InlineData(720, "1mo")] + [InlineData(36, "36h")] + [InlineData(2160, "90d")] + public void FromLegacyHours_MapsWholeHours(int hours, string expectedId) + { + var spec = TimeRangePresets.FromLegacyHours(hours); + Assert.NotNull(spec); + Assert.Equal(expectedId, spec!.Id); + Assert.Equal(hours, spec.WholeHours); + Assert.True(spec.TryResolve(Now, NewYork, out var range, out _)); + Assert.Equal(TimeSpan.FromHours(hours), range!.Span); + } + + [Theory] + [InlineData(0)] + [InlineData(-4)] + public void FromLegacyHours_RefusesZeroAndNegative(int hours) + => Assert.Null(TimeRangePresets.FromLegacyHours(hours)); + + [Fact] + public void Ids_RoundTrip() + { + var specs = TimeRangePresets.Rolling.Concat(TimeRangePresets.CalendarPeriods).ToList(); + specs.Add(TimeRangeSpec.Relative(TimeSpan.FromMinutes(90))); + specs.Add(TimeRangeSpec.FixedRange(new DateTime(2026, 10, 1, 4, 0, 0), new DateTime(2026, 10, 3, 4, 0, 0))); + specs.Add(TimeRangeSpec.SinceInstant(new DateTime(2026, 10, 1, 4, 0, 0))); + foreach (var spec in specs) + { + Assert.True(TimeRangeSpec.TryFromId(spec.Id, out var back), spec.Id); + Assert.Equal(spec, back); + } + + Assert.False(TimeRangeSpec.TryFromId("", out _)); + Assert.False(TimeRangeSpec.TryFromId("tomorrow", out _)); + Assert.False(TimeRangeSpec.TryFromId("0h", out _)); + Assert.False(TimeRangeSpec.TryFromId("h", out _)); + Assert.Equal(TimeSpan.FromDays(60), TimeRangeSpec.TryFromId("2mo", out var twoMonths) ? twoMonths!.Span : default); + } + + [Fact] + public void TheMinimumIsFiveMinutes_AndNothingIsWidened() + { + Assert.True(TimeRangeSpec.Relative(TimeSpan.FromMinutes(5)).TryResolve(Now, NewYork, out _, out _)); + Assert.False(TimeRangeSpec.Relative(TimeSpan.FromMinutes(4)).TryResolve(Now, NewYork, out var none, out var error)); + Assert.Null(none); + Assert.Equal("too_short", error!.Code); + Assert.Contains("5 minutes", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void NoUpperCap_AYearsLongRangeResolves() + { + var result = TimeRangeParser.Parse("1000d", Now, NewYork); + Assert.True(result.Ok); + Assert.Equal(TimeSpan.FromDays(1000), result.Range!.Span); + } + + [Fact] + public void TypedAndPresetRanges_AgreeOnTheSameInstants() + { + Assert.Equal(TimeRangeSpec.Relative(TimeSpan.FromDays(7)), TimeRangeParser.Parse("7d", Now, NewYork).Spec); + Assert.Equal(TimeRangePresets.Find("1mo"), TimeRangeParser.Parse("30 days", Now, NewYork).Spec); + Assert.Equal(TimeRangeSpec.ForPeriod(CalendarPeriod.PreviousMonth), TimeRangeParser.Parse("last month", Now, NewYork).Spec); + Assert.Equal(TimeRangeSpec.Relative(TimeSpan.FromDays(30)), TimeRangeParser.Parse("last 30 days", Now, NewYork).Spec); + } + + [Fact] + public void Resolve_FollowsTheZone_ForCalendarPeriodsButNotForFixedRanges() + { + var today = TimeRangeSpec.ForPeriod(CalendarPeriod.Yesterday); + Assert.True(today.TryResolve(Now, NewYork, out var ny, out _)); + Assert.True(today.TryResolve(Now, TimeZoneInfo.Utc, out var utc, out _)); + Assert.NotEqual(ny!.StartUtc, utc!.StartUtc); + + var fixedRange = TimeRangeParser.Parse("Oct 1", Now, NewYork).Spec!; + Assert.True(fixedRange.TryResolve(Now, NewYork, out var a, out _)); + Assert.True(fixedRange.TryResolve(Now, TimeZoneInfo.Utc, out var b, out _)); + Assert.Equal(a!.StartUtc, b!.StartUtc); + Assert.Equal(a.EndUtc, b.EndUtc); + } + + [Fact] + public void LiveRanges_SlideWithNow() + { + var spec = TimeRangeParser.Parse("since 10/1", Now, NewYork).Spec!; + Assert.True(spec.TryResolve(Now, NewYork, out var first, out _)); + Assert.True(spec.TryResolve(Now.AddHours(3), NewYork, out var later, out _)); + Assert.Equal(first!.StartUtc, later!.StartUtc); + Assert.Equal(first.EndUtc.AddHours(3), later.EndUtc); + Assert.True(later.IsLive); + } + + [Fact] + public void Label_ForTheIssueExample() + { + var result = TimeRangeParser.Parse("week to date", new DateTime(2026, 10, 8, 11, 1, 0), NewYork); + Assert.Equal("3d Oct 5, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", result.Echo); + Assert.Equal("Oct 5, 12:00 am", result.Range!.StartText); + Assert.Equal("UTC-04:00", result.Range.ZoneText); + } + + [Fact] + public void DataStartNote_AppearsOnlyWhenTheRangeStartsWellBeforeTheData() + { + Assert.True(TimeRangeSpec.Relative(TimeSpan.FromDays(7)).TryResolve(Now, NewYork, out var week, out _)); + // Data begins Oct 3 2:00 pm EDT = 18:00Z; the week starts Oct 1 7:01 am EDT. + Assert.Equal("Data starts Oct 3, 2:00 pm", TimeRangeNotes.DataStartNote(week!, new DateTime(2026, 10, 3, 18, 0, 0))); + // Starting inside the 90 minute slack of the data's start says nothing. + Assert.Null(TimeRangeNotes.DataStartNote(week!, new DateTime(2026, 10, 1, 12, 0, 0))); + Assert.Null(TimeRangeNotes.DataStartNote(week!, null)); + Assert.Null(TimeRangeNotes.DataStartNote(week!, new DateTime(2026, 9, 1))); + } + + [Theory] + [InlineData(10, 5, "Data here is collected every 5 minutes.")] + [InlineData(14, 5, "Data here is collected every 5 minutes.")] + [InlineData(15, 5, null)] + [InlineData(5, 1, null)] + [InlineData(5, 2, "Data here is collected every 2 minutes.")] + [InlineData(120, 60, "Data here is collected every hour.")] + [InlineData(60, 0, null)] + public void SampleIntervalNote_FiresUnderThreeSamples(int spanMinutes, int intervalMinutes, string? expected) + { + TimeSpan? interval = intervalMinutes == 0 ? null : TimeSpan.FromMinutes(intervalMinutes); + Assert.Equal(expected, TimeRangeNotes.SampleIntervalNote(TimeSpan.FromMinutes(spanMinutes), interval)); + } + + [Fact] + public void FormatLength_FloorsAndDropsHoursPastADay() + { + Assert.Equal("45m", TimeRangeSpec.FormatLength(TimeSpan.FromMinutes(45))); + Assert.Equal("1h 30m", TimeRangeSpec.FormatLength(TimeSpan.FromMinutes(90))); + Assert.Equal("2h", TimeRangeSpec.FormatLength(TimeSpan.FromHours(2))); + Assert.Equal("1d", TimeRangeSpec.FormatLength(TimeSpan.FromHours(47))); + Assert.Equal("3d", TimeRangeSpec.FormatLength(new TimeSpan(3, 7, 1, 0))); + } +} diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj index 6753fc6ed0..b393361c01 100644 --- a/Lite.Tests/Lite.Tests.csproj +++ b/Lite.Tests/Lite.Tests.csproj @@ -61,6 +61,9 @@ + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +