diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c241c6214..b3d9740d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -693,6 +693,8 @@ jobs: - 'Lite/**/*.csproj' # #4966: Darling.Tests' CommentFilterAdoptionTests reads this Lite.Tests file, so an edit to it has to reach the Darling suite. - 'Lite.Tests/DataStartBannerQueriesTabTests.cs' + # #5562: same, for the Lite.Tests file CommentFilterAdoptionTests lists as a stated bound. + - 'Lite.Tests/LiteTimeRangeGapTests.cs' # #5596: Darling.Tests' WpfExitSwitchPinTests reads this project file for the WPF exit switch, so an edit to it has to reach the Darling suite. - 'Lite.Tests/Lite.Tests.csproj' # #5459 cut 3a: the part of `darling` that is NOT a Lite file. A diff that reaches `darling` through diff --git a/Darling/Darling.Tests/AlertHistoryRangePageTests.cs b/Darling/Darling.Tests/AlertHistoryRangePageTests.cs index b2b3984a3..f1d05c232 100644 --- a/Darling/Darling.Tests/AlertHistoryRangePageTests.cs +++ b/Darling/Darling.Tests/AlertHistoryRangePageTests.cs @@ -32,8 +32,13 @@ public void ThePageReadsGetAlertHistoryWithTheFourChoicesAndNeverAFixedWindow() var page = Page(); Assert.DoesNotContain("hours_back: 24", page); Assert.DoesNotContain("limit: 200 }", page); - Assert.Contains("readTool(\"get_alert_history\", readParams())", page); - Assert.Contains("hours_back: choices.hours", page); + Assert.Contains("readTool(\"get_alert_history\", req.params)", page); + // #5562: the range is the shared picker's spec, read as of each read; a finished range also sends its end. + Assert.Contains("windowResultOfSpec(choices.spec);", page); + Assert.Contains("hours_back: w.hours", page); + Assert.Contains("as_of: w.asOf", page); + // Review r1 H2: the reply is trimmed to the range start, since the read takes whole hours. + Assert.Contains("trimAlertsToStart(res.data.alerts || [], req.startMs)", page); Assert.Contains("limit: choices.limit", page); Assert.Contains("server_name: choices.server || null", page); Assert.Contains("include_dismissed: choices.dismissed ? \"true\" : null", page); @@ -45,9 +50,14 @@ public void ThePageReadsGetAlertHistoryWithTheFourChoicesAndNeverAFixedWindow() public void NoWindowOrLimitIsOfferedThatTheToolOrTheDispatchLayerRefuses() { var page = Page(); - var hours = System.Text.RegularExpressions.Regex.Matches(page, "\\{ hours: (\\d+), label").Select(m => int.Parse(m.Groups[1].Value)).ToList(); - Assert.Equal(new[] { 1, 4, 24, 168 }, hours); - Assert.True(hours.Max() <= PerformanceMonitor.Common.McpHelpers.MaxHoursBack); + // #5562 R8: no hand-listed windows and no "All". The picker takes its reach from the catalog's max_hours for the read, which is + // the 2160 hours (90 days) the read's validator accepts, so a longer range is greyed out with the reason and never clamped. That + // is the alert table's retention (AlertHistoryRetentionDays, DarlingRetention.cs), the longest choice the Viewer offers (R8, L4b). + Assert.DoesNotContain("WINDOW_CHOICES", page); + Assert.Contains("read: \"get_alert_history\"", page); + Assert.Contains("pageRangePicker({", page); + Assert.Equal(24 * PerformanceMonitor.Darling.Storage.DarlingRetentionHorizons.AlertHistoryRetentionDays, PerformanceMonitor.Darling.Service.WebReadReach.All["get_alert_history"].MaxHours); + Assert.Equal(90, PerformanceMonitor.Darling.Storage.DarlingRetentionHorizons.AlertHistoryRetentionDays); var limits = System.Text.RegularExpressions.Regex.Match(page, "const LIMIT_CHOICES = \\[([0-9, ]+)\\]").Groups[1].Value .Split(',').Select(s => int.Parse(s.Trim())).ToList(); @@ -63,12 +73,17 @@ public void TheChoicesLiveAtModuleScopeAndTheDismissRouteIsNotTheDesktopOne() Assert.DoesNotContain("/api/alerts/dismiss", page); } - private static JsonElement Run(string scenario) + private static JsonElement Run(string scenario, string? timeZone = null) { var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "alert-history-range-harness.mjs")); psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js")); psi.ArgumentList.Add(scenario); + if (timeZone is not null) + { + psi.ArgumentList.Add(timeZone); + } + Process proc; try { @@ -118,7 +133,15 @@ public void EachChoiceReachesTheRead() Assert.Equal("1000", Get(dismissed, "limit")); Assert.Equal("srv-b", Get(dismissed, "server_name")); - Assert.Equal("1,4,24,168", string.Join(",", r.GetProperty("windowOptions").EnumerateArray().Select(e => e.GetString()))); + // The popup offers every preset and calendar period within the catalog's 90 days; the longer ones are greyed out, never clamped. + Assert.Equal("Past 5 minutes,Past 15 minutes,Past 30 minutes,Past hour,Past 4 hours,Past day,Past 2 days,Past week,Past 30 days,Past 90 days,Today,Yesterday,Week to Date,Previous Week,Month to Date,Previous Month", + string.Join(",", r.GetProperty("windowOptions").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal("Year to Date,Previous Year", + string.Join(",", r.GetProperty("windowGreyed").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal("This page's reads reach at most 90 days back.", r.GetProperty("refused").GetString()); + // A finished range sends its end as as_of; a shorter-than-an-hour range reads the whole hour. + Assert.EndsWith("Z", Get(r.GetProperty("finished"), "as_of")); + Assert.Equal("1", Get(r.GetProperty("thirtyMinutes"), "hours_back")); Assert.Equal("200,500,1000", string.Join(",", r.GetProperty("limitOptions").EnumerateArray().Select(e => e.GetString()))); Assert.Equal(",srv-a,srv-b", string.Join(",", r.GetProperty("serverOptions").EnumerateArray().Select(e => e.GetString()))); } @@ -138,7 +161,7 @@ public void TheChoicesSurviveThePollAndAVisitElsewhere() } var shown = r.GetProperty("shown"); - Assert.Equal("4", shown.GetProperty("window").GetString()); + Assert.Equal("Past 4 hours", shown.GetProperty("window").GetString()); Assert.Equal("500", shown.GetProperty("limit").GetString()); Assert.Equal("srv-a", shown.GetProperty("server").GetString()); Assert.True(shown.GetProperty("dismissed").GetBoolean()); @@ -182,9 +205,13 @@ public void TwoServersThatShareADisplayNameKeepBothRows() } [Fact] - public void TheRangePickerSaysTheWebReadsAtMostSevenDays() + public void TheRangePickerTakesItsReachFromTheCatalog_NinetyDays() { - Assert.Contains("The web reads at most 7 days", Page()); + // #5562: the picker greys out what the read cannot reach and says why (the catalog's max_hours), so the page needs no hand-written + // tooltip; the reach is the read's own 2160 hours, the alert table's retention (L4b). + Assert.DoesNotContain("The web reads at most 7 days of alert history, so there is no All choice.", Page()); + Assert.DoesNotContain("the web read stops at the common 7 day reach", Page()); + Assert.Contains("get_alert_history reaches 2160 hours, 90 days", Page()); } [Fact] @@ -197,6 +224,25 @@ public void ANewWindowReconcilesInPlace_RowsLeaveAndNewOnesLandInOrder() Assert.Equal("High CPU 3", string.Join(",", r.GetProperty("shrunk").EnumerateArray().Select(e => e.GetString()))); } + // Review r2 H1: alert_time is naive UTC with no zone suffix, which the browser reads as LOCAL time unless the page parses it as UTC. The + // harness rows carry the server's real shape, and the same scenario runs in zones that are not UTC (and in UTC) because a UTC-only run + // passes the old Date.parse code. The offset check proves Node honoured the zone, so the test cannot pass by running in UTC every time. + [Theory] + [InlineData("UTC", 0)] + [InlineData("Europe/Berlin", -1)] + [InlineData("America/New_York", 1)] + [InlineData("Asia/Tokyo", -1)] + public void ARangeShorterThanTheWholeHoursItFetches_ListsOnlyTheAlertsInsideIt(string zone, int offsetSign) + { + var r = Run("trim", zone); + Assert.Equal(offsetSign, Math.Sign(r.GetProperty("tzOffsetMinutes").GetInt32())); + string[] Names(string key) => r.GetProperty(key).EnumerateArray().Select(e => e.GetString()!).ToArray(); + Assert.Equal(new[] { "High CPU 0", "High CPU 19", "High CPU 89" }, Names("day")); + Assert.Equal(new[] { "High CPU 0" }, Names("fiveMinutes")); + Assert.Equal(new[] { "High CPU 0", "High CPU 19" }, Names("thirtyMinutes")); + Assert.Equal(new[] { "High CPU 0", "High CPU 19", "High CPU 89" }, Names("fourHours")); + } + [Fact] public void TheMuteLinksStillRenderOnEveryRow() { diff --git a/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt b/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt index 5df7eba88..666e6ac57 100644 --- a/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt +++ b/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt @@ -8,7 +8,6 @@ MouseDoubleClick=>QueryStoreRegressionsGrid_MouseDoubleClick SelectionChanged=>AutoRefreshInterval_Changed SelectionChanged=>BlockingSubTabs_SelectionChanged SelectionChanged=>CompareToCombo_SelectionChanged -SelectionChanged=>CustomTimeCombo_Changed SelectionChanged=>FileIoSubTabs_SelectionChanged SelectionChanged=>HeatmapMetric_SelectionChanged SelectionChanged=>InnerTabs_SelectionChanged @@ -19,5 +18,4 @@ SelectionChanged=>QueryStatsGrid_SelectionChanged SelectionChanged=>QueryStoreGrid_SelectionChanged SelectionChanged=>SystemEventsSubTabs_SelectionChanged SelectionChanged=>TimeDisplayMode_SelectionChanged -SelectionChanged=>TimeRangeCombo_SelectionChanged SelectionChanged=>WaitStatsMetric_SelectionChanged diff --git a/Darling/Darling.Tests/CommentFilterAdoptionTests.cs b/Darling/Darling.Tests/CommentFilterAdoptionTests.cs index 8d4e58c7e..d96e28c25 100644 --- a/Darling/Darling.Tests/CommentFilterAdoptionTests.cs +++ b/Darling/Darling.Tests/CommentFilterAdoptionTests.cs @@ -85,7 +85,7 @@ public sealed class CommentFilterAdoptionTests /// name a new entry. /// /// Four kinds live here and they are not the same kind. Eight collect a doc-comment run, - /// where the prefix is what defines the run. Two read a stated, measured bound off a named file each. + /// where the prefix is what defines the run. Three read a stated, measured bound off a named file each. /// One filters SQL, which the C# walk cannot help with. One demonstrates the shape on an /// arranged fixture, which is this file. /// @@ -168,6 +168,15 @@ public sealed class CommentFilterAdoptionTests + "which is a pass for the wrong reason. Measurement-dependent, not permanent: a second block " + "comment in that file makes the bound wrong.", + ["Lite.Tests/LiteTimeRangeGapTests.cs"] = + "STATED BOUND, measured. It COUNTS call sites of ApplyWindowFloorToBanner( across Lite's tab sources (#5562 R7: every banner site feeds the " + + "picker's data start). IsDocOrDefinition drops a match whose line, trimmed, starts with //, ///, * or /*, so a " + + "doc or comment mention is not a site, and drops the method's own definition line. Stated bound: it reads one " + + "line, so a call written inside a block comment on a line that does not begin with * or /* is miscounted as a " + + "site (a spurious extra site, the loud direction: the per-file count then disagrees with the sites pinned). " + + "The walker would drop the call as comment text correctly, but the same test also needs the definition line " + + "kept apart, which the line check does in one step; the sources it reads carry no such block comment today.", + ["Darling.Tests/Pg18IoBytesTests.cs"] = "NOT C#. The filter runs over DarlingPgIoReader.PgIoSql - SQL text already extracted from a raw " + "string literal - and drops SQL block-comment lines from the outer SELECT list before counting " diff --git a/Darling/Darling.Tests/Darling.Tests.csproj b/Darling/Darling.Tests/Darling.Tests.csproj index 1f97d9123..da603d2a0 100644 --- a/Darling/Darling.Tests/Darling.Tests.csproj +++ b/Darling/Darling.Tests/Darling.Tests.csproj @@ -62,6 +62,8 @@ + + diff --git a/Darling/Darling.Tests/DarlingMcpJobHistoryToolTests.cs b/Darling/Darling.Tests/DarlingMcpJobHistoryToolTests.cs index 6329b09b8..a57612dc8 100644 --- a/Darling/Darling.Tests/DarlingMcpJobHistoryToolTests.cs +++ b/Darling/Darling.Tests/DarlingMcpJobHistoryToolTests.cs @@ -299,7 +299,7 @@ public async Task ViewerRows_EqualTheStorageReaderRows_MappedThroughFrom() var since = AsOf.AddHours(-24); await using var viewer = new ViewerDataService(Cs!); - var viewerRows = await viewer.GetJobHistoryAsync(since, null, 2000, ct); + var viewerRows = await viewer.GetJobHistoryAsync(since, null, 2000, cancellationToken: ct); var storage = await DarlingJobHistoryReader.GetAsync(postgres, since, null, 2000, 60, cancellationToken: ct); /* D5: the viewer stamps each row with its own server's clock, which the storage reader's row does not carry, and diff --git a/Darling/Darling.Tests/DarlingWebAssetsTests.cs b/Darling/Darling.Tests/DarlingWebAssetsTests.cs index 41999e72e..bdbc7f058 100644 --- a/Darling/Darling.Tests/DarlingWebAssetsTests.cs +++ b/Darling/Darling.Tests/DarlingWebAssetsTests.cs @@ -40,6 +40,7 @@ public void Wwwroot_IndexHtml_IsCopiedToTheBuildOutput() [InlineData("css/theme.css")] [InlineData("css/app.css")] [InlineData("css/editor.css")] + [InlineData("css/time-range.css")] [InlineData("js/app.js")] [InlineData("js/util.js")] [InlineData("js/panels.js")] diff --git a/Darling/Darling.Tests/FinOpsPageShellTests.cs b/Darling/Darling.Tests/FinOpsPageShellTests.cs index 52269a4a3..abe1fbb7a 100644 --- a/Darling/Darling.Tests/FinOpsPageShellTests.cs +++ b/Darling/Darling.Tests/FinOpsPageShellTests.cs @@ -168,7 +168,8 @@ public void EveryTabFile_ExportsTabWithIdLabelBuild_AndTheIdIsTheFileName() .Select(Path.GetFileNameWithoutExtension) // database-box.js is the shared Database box the Index Analysis and Locking tabs import (#5231), not a tab. // gate.js is the helper the tabs share for their empty and not-collected states on a PostgreSQL target, also not a tab. - .Where(name => name != "database-box" && name != "gate") + // window.js is the rolling-only time range picker the windowed tabs share (#5562), also not a tab. + .Where(name => name != "database-box" && name != "gate" && name != "window") .OrderBy(x => x, StringComparer.Ordinal).ToArray(); Assert.Equal(TabIds.OrderBy(x => x, StringComparer.Ordinal).ToArray(), onDisk); diff --git a/Darling/Darling.Tests/FinOpsStorageGrowthWindowBehaviourTests.cs b/Darling/Darling.Tests/FinOpsStorageGrowthWindowBehaviourTests.cs index 430c977cb..05ed63e65 100644 --- a/Darling/Darling.Tests/FinOpsStorageGrowthWindowBehaviourTests.cs +++ b/Darling/Darling.Tests/FinOpsStorageGrowthWindowBehaviourTests.cs @@ -84,13 +84,21 @@ public void TheObjectsLevelDefaultsToThirtyDays_AndEachPickSendsDaysTimes24() var run = Run(); var objects = run.GetProperty("objects"); Assert.Equal(new[] { "720" }, Hours(objects)); - Assert.Equal("7,30,90", string.Join(",", objects.GetProperty("options").EnumerateArray().Select(e => e.GetString()))); - Assert.Equal("30", objects.GetProperty("value").GetString()); + // #5562 R5: the Compact rolling-only picker, whole days, reach 2160 hours for this view (not the catalog entry's 168). + Assert.Equal("Past week,Past 30 days,Past 90 days", string.Join(",", objects.GetProperty("options").EnumerateArray().Select(e => e.GetString()))); + var refused = objects.GetProperty("refused"); + Assert.Equal("This page reads whole days back from now, such as 7d or 30d.", refused.GetProperty("hours").GetString()); + Assert.Equal("This page reads a length back from now, so pick a rolling length such as 24h or 7d.", refused.GetProperty("calendar").GetString()); + Assert.Equal("This page's reads reach at most 90 days back.", refused.GetProperty("tooLong").GetString()); + /* Review r1 H1: 1 day would read the server's 30-day default and 2 to 6 days are refused by the read, so neither is offered. */ + Assert.Equal("This page reads at least 7d back from now.", refused.GetProperty("tooShort").GetString()); + Assert.Equal("This page reads at least 7d back from now.", refused.GetProperty("oneDay").GetString()); + Assert.Equal("Past 30 days", objects.GetProperty("value").GetString()); Assert.Equal(new[] { "2160" }, Hours(run.GetProperty("picked90"))); - Assert.Equal("90", run.GetProperty("picked90").GetProperty("value").GetString()); + Assert.Equal("Past 90 days", run.GetProperty("picked90").GetProperty("value").GetString()); Assert.Equal(new[] { "168" }, Hours(run.GetProperty("picked7"))); - Assert.Equal("7", run.GetProperty("picked7").GetProperty("value").GetString()); + Assert.Equal("Past week", run.GetProperty("picked7").GetProperty("value").GetString()); } [Fact] @@ -99,11 +107,11 @@ public void ThePickSurvivesARebuildForTheSameServer_AndAnotherServerStartsAtThir var run = Run(); var rebuilt = run.GetProperty("rebuilt"); Assert.Equal(new[] { "168" }, Hours(rebuilt)); - Assert.Equal("7", rebuilt.GetProperty("value").GetString()); + Assert.Equal("Past week", rebuilt.GetProperty("value").GetString()); Assert.Equal(new[] { "24" }, Hours(run.GetProperty("other"))); var otherObjects = run.GetProperty("otherObjects"); Assert.Equal(new[] { "720" }, Hours(otherObjects)); - Assert.Equal("30", otherObjects.GetProperty("value").GetString()); + Assert.Equal("Past 30 days", otherObjects.GetProperty("value").GetString()); } } diff --git a/Darling/Darling.Tests/FinOpsTabDatabaseResourcesPageTests.cs b/Darling/Darling.Tests/FinOpsTabDatabaseResourcesPageTests.cs index 5052aac8b..06b2fe1a4 100644 --- a/Darling/Darling.Tests/FinOpsTabDatabaseResourcesPageTests.cs +++ b/Darling/Darling.Tests/FinOpsTabDatabaseResourcesPageTests.cs @@ -36,18 +36,16 @@ public void TheTabReadsGetFinOpsWithTheDatabaseResourcesViewWindow() [Fact] public void ThePickerOffersTheDesktopWindowsInOrder() { - // Source: FinOpsTab.xaml ~:497-499, FinOpsResourceUsageTimeRangeCombo. + // #5562 R5: the shared picker in its Compact, rolling-only form (finops/window.js), not a hand-listed select. var tab = Tab(); - var pairs = Regex.Matches(tab, "\\{ value: (\\d+), label: \"([^\"]+)\" \\}").Select(m => m.Groups[1].Value + "=" + m.Groups[2].Value); - Assert.Equal("1=Last 1 hour,4=Last 4 hours,12=Last 12 hours,24=Last 24 hours,168=Last 7 days", string.Join(",", pairs)); - Assert.Contains("pickerControl(\"Window\", WINDOWS, HOURS,", tab); + Assert.DoesNotContain("WINDOWS", tab); + Assert.Contains("finopsWindowControl({ hours: HOURS, onChange: (hours) => load(hours) })", tab); } [Fact] public void TheChangeHandlerRereadsWithTheChosenHoursAndTheFirstReadUsesTheDefault() { var tab = Tab(); - Assert.Contains("sel.addEventListener(\"change\", () => onPick(Number(sel.value)));", tab); Assert.Contains("(hours) => load(hours)", tab); Assert.Contains("load(HOURS);", tab); } @@ -127,7 +125,7 @@ public void TheTabImportsOnlyTheSharedHelpers() { var imports = Regex.Matches(Tab(), "from \"([^\"]+)\";").Select(m => m.Groups[1].Value).ToList(); Assert.NotEmpty(imports); - Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "./gate.js" })); + Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "./gate.js", "./window.js" })); Assert.Contains("./gate.js", imports); } diff --git a/Darling/Darling.Tests/FinOpsTabHighImpactPageTests.cs b/Darling/Darling.Tests/FinOpsTabHighImpactPageTests.cs index 39ecedc0d..3edf9a168 100644 --- a/Darling/Darling.Tests/FinOpsTabHighImpactPageTests.cs +++ b/Darling/Darling.Tests/FinOpsTabHighImpactPageTests.cs @@ -34,9 +34,9 @@ public void TheTabReadsGetFinOpsWithTheHighImpactViewWindowAndLimit() public void ThePickerOffersTheDesktopWindowsAndTheStateIsKeptPerServer() { var tab = Tab(); - var pairs = Regex.Matches(tab, "\\{ value: (\\d+), label: \"([^\"]+)\" \\}").Select(m => m.Groups[1].Value + "=" + m.Groups[2].Value); - Assert.Equal("1=Last 1 hour,4=Last 4 hours,12=Last 12 hours,24=Last 24 hours,168=Last 7 days", string.Join(",", pairs)); - Assert.Contains("pickerControl(\"Window\", WINDOWS, start,", tab); + // #5562 R5: the shared picker in its Compact, rolling-only form (finops/window.js), not a hand-listed select. + Assert.DoesNotContain("WINDOWS", tab); + Assert.Contains("finopsWindowControl({ hours: start, onChange: (hours) => load(hours) })", tab); Assert.Contains("const chosenHours = new Map();", tab); Assert.Contains("chosenHours.set(server, hours);", tab); Assert.Contains("chosenHours.get(server) ?? HOURS", tab); @@ -77,7 +77,7 @@ public void TheTabImportsOnlyTheSharedHelpers() { var imports = Regex.Matches(Tab(), "from \"([^\"]+)\";").Select(m => m.Groups[1].Value).ToList(); Assert.NotEmpty(imports); - Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "../plan-viewer.js", "./gate.js" })); + Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "../plan-viewer.js", "./gate.js", "./window.js" })); Assert.Contains("./gate.js", imports); } diff --git a/Darling/Darling.Tests/FinOpsTabOptimizationPageTests.cs b/Darling/Darling.Tests/FinOpsTabOptimizationPageTests.cs index 9fdfc9db7..7131a4657 100644 --- a/Darling/Darling.Tests/FinOpsTabOptimizationPageTests.cs +++ b/Darling/Darling.Tests/FinOpsTabOptimizationPageTests.cs @@ -66,9 +66,9 @@ private static void AssertKeysMatchRow(string constName, string method, string c public void ThePickerOffersTheDesktopWindowsAndTheStateIsKeptPerServer() { var tab = Tab(); - var pairs = Regex.Matches(tab, "\\{ value: (\\d+), label: \"([^\"]+)\" \\}").Select(m => m.Groups[1].Value + "=" + m.Groups[2].Value); - Assert.Equal("1=Last 1 hour,4=Last 4 hours,12=Last 12 hours,24=Last 24 hours,168=Last 7 days", string.Join(",", pairs)); - Assert.Contains("pickerControl(\"Window\", WINDOWS, start,", tab); + // #5562 R5: the shared picker in its Compact, rolling-only form (finops/window.js), not a hand-listed select. + Assert.DoesNotContain("WINDOWS", tab); + Assert.Contains("finopsWindowControl({ hours: start, onChange: (hours) => load(hours) })", tab); Assert.Contains("const chosenHours = new Map();", tab); Assert.Contains("chosenHours.set(server, hours);", tab); Assert.Contains("chosenHours.get(server) ?? HOURS", tab); @@ -188,7 +188,7 @@ public void TheTabImportsOnlyTheSharedHelpers() { var imports = Regex.Matches(Tab(), "from \"([^\"]+)\";").Select(m => m.Groups[1].Value).ToList(); Assert.NotEmpty(imports); - Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "../plan-viewer.js", "./gate.js" })); + Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "../plan-viewer.js", "./gate.js", "./window.js" })); Assert.Contains("./gate.js", imports); } diff --git a/Darling/Darling.Tests/FinOpsTabStorageGrowthPageTests.cs b/Darling/Darling.Tests/FinOpsTabStorageGrowthPageTests.cs index ecba0fd6b..bc802db47 100644 --- a/Darling/Darling.Tests/FinOpsTabStorageGrowthPageTests.cs +++ b/Darling/Darling.Tests/FinOpsTabStorageGrowthPageTests.cs @@ -254,7 +254,7 @@ public void TheTabImportsOnlyWhatItUses() { var tab = Tab(); var imports = Regex.Matches(tab, "from \"([^\"]+)\";").Select(m => m.Groups[1].Value).ToList(); - Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../util.js", "./gate.js" })); + Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../util.js", "./gate.js", "./window.js" })); Assert.Contains("./gate.js", imports); var names = Regex.Match(tab, "import \\{([^}]*)\\} from \"../../util.js\"").Groups[1].Value.Split(',').Select(n => n.Trim()); var body = tab.Substring(tab.IndexOf("const HOURS", System.StringComparison.Ordinal)); diff --git a/Darling/Darling.Tests/FinOpsWindowPickerBehaviourTests.cs b/Darling/Darling.Tests/FinOpsWindowPickerBehaviourTests.cs index ae82b5c16..6d1c38262 100644 --- a/Darling/Darling.Tests/FinOpsWindowPickerBehaviourTests.cs +++ b/Darling/Darling.Tests/FinOpsWindowPickerBehaviourTests.cs @@ -67,22 +67,31 @@ public void ThePickKeepsAcrossARebuildForTheSameServerAndResetsForAnother(string var first = t.GetProperty("first"); Assert.Equal(new[] { "24" }, Hours(first)); - Assert.Equal("1,4,12,24,168", string.Join(",", first.GetProperty("options").EnumerateArray().Select(e => e.GetString()))); + // #5562 R5: the shared picker in its Compact, rolling-only form. Every FinOps read is hours back from now, so it offers + // no calendar period and no start-and-end boxes, nothing under an hour, and no length past the read's 7 day reach. + Assert.Equal("Past hour,Past 4 hours,Past day,Past 2 days,Past week", string.Join(",", first.GetProperty("options").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal("Quick ranges,Type a length", string.Join(",", first.GetProperty("shape").GetProperty("headings").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal(0, first.GetProperty("shape").GetProperty("dateBoxes").GetInt32()); + var refused = first.GetProperty("refused"); + Assert.Equal("This page reads at least 1h back from now.", refused.GetProperty("subHour").GetString()); + Assert.Equal("This page reads a length back from now, so pick a rolling length such as 24h or 7d.", refused.GetProperty("calendar").GetString()); + Assert.Equal("This page reads whole hours back from now, such as 6h or 3d.", refused.GetProperty("fractional").GetString()); + Assert.Equal("This page's reads reach at most 7 days back.", refused.GetProperty("tooLong").GetString()); // A pick reads again with the chosen hours. var picked = t.GetProperty("picked"); Assert.Equal(new[] { "24", "4" }, Hours(picked)); - Assert.Equal("4", picked.GetProperty("select").GetString()); + Assert.Equal("Past 4 hours", picked.GetProperty("select").GetString()); // The poll rebuilds the tab: same server, same window, one read with it. var rebuilt = t.GetProperty("rebuilt"); Assert.Equal(new[] { "4" }, Hours(rebuilt)); - Assert.Equal("4", rebuilt.GetProperty("select").GetString()); + Assert.Equal("Past 4 hours", rebuilt.GetProperty("select").GetString()); // Another server starts at the default. var other = t.GetProperty("other"); Assert.Equal(new[] { "24" }, Hours(other)); - Assert.Equal("24", other.GetProperty("select").GetString()); + Assert.Equal("Past day", other.GetProperty("select").GetString()); } [Fact] 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 000000000..b0376869c --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/time-range-cases.json @@ -0,0 +1,1149 @@ +{ + "_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 unless the row also carries errorMessage, the exact words both front ends must give.", + "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": "2026-10-08T04:00:00Z", + "end": "2026-10-08T04:02:00Z", + "live": true, + "echo": "2m Oct 8, 12:00 am - Oct 8, 12:02 am (UTC-04:00)", + "error": null + }, + { + "name": "today at exactly midnight has not started", + "input": "today", + "now": "2026-10-08T04:00:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_short", + "errorMessage": "Today has only just started. Pick another range." + }, + { + "name": "week to date at Monday midnight has not started", + "input": "week to date", + "now": "2026-10-05T04:00:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "too_short", + "errorMessage": "Week to Date has only just started. Pick another range." + }, + { + "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": "a fixed range that starts in the future is refused (review r2 M1)", + "input": "Dec 25 2030 - Dec 26 2030", + "now": "2026-10-08T11:01:00Z", + "zone": "America/New_York", + "start": null, + "end": null, + "live": null, + "echo": null, + "error": "range_not_started", + "errorMessage": "That range has not started yet." + }, + { + "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/FleetIdentifierScrubTests.cs b/Darling/Darling.Tests/FleetIdentifierScrubTests.cs index 43d34e4b0..4207ee9f7 100644 --- a/Darling/Darling.Tests/FleetIdentifierScrubTests.cs +++ b/Darling/Darling.Tests/FleetIdentifierScrubTests.cs @@ -80,6 +80,8 @@ fleet has a pg-* store as well as use[0-9] ones. private static readonly HashSet OrdinalRoleWords = new(StringComparer.OrdinalIgnoreCase) { "prod", "dev", "local", "sql", "aurora", "srv", "box", "web", "target", "bench", "gone", + // A zone offset in the time range picker's label and tests ("UTC-04:00", #5562): a clock offset, never a tenant. + "utc", }; /* diff --git a/Darling/Darling.Tests/JobHistoryGroupedStatsMatchWindowFunctionLiveTests.cs b/Darling/Darling.Tests/JobHistoryGroupedStatsMatchWindowFunctionLiveTests.cs index 64f91401c..974e8a049 100644 --- a/Darling/Darling.Tests/JobHistoryGroupedStatsMatchWindowFunctionLiveTests.cs +++ b/Darling/Darling.Tests/JobHistoryGroupedStatsMatchWindowFunctionLiveTests.cs @@ -170,7 +170,7 @@ await InsertJobHistoryAsync(connection, ct, id: 201, at: tied.AddSeconds(1), job const int limit = 5; var oldRows = await ReadOldAsync(connection, sinceUtc, ct, limit); - var newRows = await viewer.GetJobHistoryAsync(sinceUtc, ServerId, limit, ct); + var newRows = await viewer.GetJobHistoryAsync(sinceUtc, ServerId, limit, cancellationToken: ct); /* Same count, same rows, same order, same values — the join replaces the window function without changing what the tab shows. */ @@ -328,7 +328,7 @@ await InsertJobHistoryAsync(connection, ct, id: 301, at: at, jobId: "shared_job" /* Fleet-wide (no server filter) is the shape whose job_stats join actually needs the server_id half; a large limit plus a client-side filter to just these two rows keeps the shared darlingtest store's other rows from being the thing that decides the LIMIT boundary here. */ - var rows = await viewer.GetJobHistoryAsync(sinceUtc, serverId: null, limit: 2000, ct); + var rows = await viewer.GetJobHistoryAsync(sinceUtc, serverId: null, limit: 2000, cancellationToken: ct); var mine = rows.Where(r => r.JobId == "shared_job" && (r.ServerId == ServerId || r.ServerId == ServerId2)).ToList(); Assert.Equal(2, mine.Count); diff --git a/Darling/Darling.Tests/JobHistoryPageTests.cs b/Darling/Darling.Tests/JobHistoryPageTests.cs index 8c248d8f5..9ad0072e8 100644 --- a/Darling/Darling.Tests/JobHistoryPageTests.cs +++ b/Darling/Darling.Tests/JobHistoryPageTests.cs @@ -36,7 +36,7 @@ private static string Wwwroot(params string[] parts) => public void ThePageReadsGetJobHistoryThroughTheSharedReadWithASignal() { var page = Page(); - Assert.Contains("readToolWithinKeptHistory(\"get_job_history\", readParams(), signal)", page); + Assert.Contains("readToolWithinKeptHistory(\"get_job_history\", readParams(w), signal)", page); Assert.Contains("readTool(\"list_servers\", {}, controller.signal)", page); Assert.Contains("VIZ.table(data,", page); Assert.Contains("rowsKey: \"runs\"", page); @@ -55,7 +55,9 @@ public void EveryParameterThePageSendsIsOneTheToolTakes() var aliases = new System.Collections.Generic.Dictionary { ["hours"] = "hours_back", ["server"] = "server_name" }; foreach (var name in sent) Assert.Contains(aliases.TryGetValue(name, out var mapped) ? mapped : name, tool); - Assert.Contains("const p = { hours: state.hours, limit: state.limit };", Page()); + // #5562: the held range gives the hours, and a finished range also sends its end as as_of. + Assert.Contains("const p = { hours: w.hours, limit: state.limit };", Page()); + Assert.Contains("if (w.asOf) p.as_of = w.asOf;", Page()); } [Fact] @@ -156,6 +158,47 @@ public void TheFirstBuildReadsTheFleetWithTheDefaultsAndTheDesktopColumns() Assert.Contains("boom", first.GetProperty("text").GetString()); } + [Fact] + public void TheRangeIsTheSharedPicker_AFinishedRangeSendsItsEnd_AndALongerOneIsRefusedNotClamped() + { + var range = Run().GetProperty("range"); + + // Past the catalog's 7 day reach: refused with the reason, and no read goes out (nothing is clamped). + Assert.Equal("This page's reads reach at most 7 days back.", range.GetProperty("past30d").GetString()); + Assert.Equal(0, range.GetProperty("past30dCalls").GetInt32()); + + // A rolling length reads whole hours back from now and sends no as_of. + var rolling = Assert.Single(range.GetProperty("twoHours").GetProperty("calls").EnumerateArray()); + Assert.Equal("2", rolling.GetProperty("hours").GetString()); + Assert.False(rolling.TryGetProperty("as_of", out _)); + + // A finished range (yesterday) sends its end: the reader bounds the runs by it before the row limit. + var finished = Assert.Single(range.GetProperty("yesterday").GetProperty("calls").EnumerateArray()); + Assert.InRange(int.Parse(finished.GetProperty("hours").GetString()!), 23, 25); + Assert.EndsWith("Z", finished.GetProperty("as_of").GetString()); + + // 30 minutes is read as the whole hour and cut at its start: the 50 minute old run goes, the 5 minute old one stays. + var half = range.GetProperty("thirtyMinutes"); + Assert.Equal("1", Assert.Single(half.GetProperty("calls").EnumerateArray()).GetProperty("hours").GetString()); + Assert.Equal("1 runs shown", Strings(half.GetProperty("shown")).Single()); + + // The popup greys out what the read cannot reach, and still offers the calendar periods and the end boxes. + Assert.Equal("Past 30 days,Month to Date,Previous Month,Year to Date,Previous Year", string.Join(",", Strings(range.GetProperty("items")))); + Assert.Equal(2, range.GetProperty("shape").GetProperty("dateBoxes").GetInt32()); + } + + [Fact] + public void TheReaderBoundsTheRunsByTheEndBeforeTheRowLimit() + { + // R6: the end is applied in SQL (JobHistoryFilter.UntilUtc), inside the per-server top-N, and again exactly in C# before the cap. + var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "DarlingJobHistoryReader.cs").ReplaceLineEndings("\n"); + Assert.Contains("AND jh.run_datetime <= ${UntilParam(firstParam)} + make_interval(mins => so.offset_minutes)", reader); + Assert.Contains("return ApplyWindow(rows, sinceUtc, limit, filter?.UntilUtc);", reader); + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpJobTools.cs").ReplaceLineEndings("\n"); + Assert.Contains("windowEnd);", tool); + Assert.Contains("DarlingJobHistoryReader.GetAsync(", tool); + } + [Fact] public void TheFiltersReachTheReadAndSurviveARebuild() { diff --git a/Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs b/Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs index 7e9235e20..8879e8433 100644 --- a/Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs +++ b/Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs @@ -211,7 +211,7 @@ await InsertJobHistoryAsync(connection, ct, id: 1, at: at, jobId: "orphan_job", serverName: "darling-4477-gone-raw"); await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM servers WHERE server_id = $1", ServerDeregistered); - var rows = await viewer.GetJobHistoryAsync(sinceUtc, serverId: null, limit: 2000, ct); + var rows = await viewer.GetJobHistoryAsync(sinceUtc, serverId: null, limit: 2000, cancellationToken: ct); var orphan = rows.SingleOrDefault(r => r.JobId == "orphan_job" && r.ServerId == ServerDeregistered); Assert.NotNull(orphan); @@ -220,7 +220,7 @@ like the old COALESCE(reg.display_name, jh.server_name) with reg NULL. */ Assert.Equal("darling-4477-gone-raw", orphan!.ServerName); /* And it is reachable server-scoped too — the read's other parameter shape. */ - var scoped = await viewer.GetJobHistoryAsync(sinceUtc, ServerDeregistered, 2000, ct); + var scoped = await viewer.GetJobHistoryAsync(sinceUtc, ServerDeregistered, 2000, cancellationToken: ct); Assert.Contains(scoped, r => r.JobId == "orphan_job"); bodySucceeded = true; @@ -327,7 +327,7 @@ so the boundary itself — not just "everything came back" — is under test. */ const int limit = 20; var oldRows = await ReadOldFleetAsync(connection, sinceUtc, limit, ct); - var newRows = await viewer.GetJobHistoryAsync(sinceUtc, serverId: null, limit, ct); + var newRows = await viewer.GetJobHistoryAsync(sinceUtc, serverId: null, limit, cancellationToken: ct); Assert.Equal(limit, newRows.Count); Assert.Equal(oldRows.Count, newRows.Count); @@ -339,7 +339,7 @@ so the boundary itself — not just "everything came back" — is under test. */ /* And scoped-to-one-server, for the server with the same-server tie — the other parameter shape, same equivalence requirement. */ var oldScopedRows = await ReadOldScopedFullAsync(connection, sinceUtc, ServerZeroOffset, limit, ct); - var newScopedRows = await viewer.GetJobHistoryAsync(sinceUtc, ServerZeroOffset, limit, ct); + var newScopedRows = await viewer.GetJobHistoryAsync(sinceUtc, ServerZeroOffset, limit, cancellationToken: ct); Assert.Equal(oldScopedRows.Count, newScopedRows.Count); for (var i = 0; i < oldScopedRows.Count; i++) { diff --git a/Darling/Darling.Tests/PerfmonWaitStatisticsArtifactMcpLiveTests.cs b/Darling/Darling.Tests/PerfmonWaitStatisticsArtifactMcpLiveTests.cs index 0dce19e3f..66a2974c4 100644 --- a/Darling/Darling.Tests/PerfmonWaitStatisticsArtifactMcpLiveTests.cs +++ b/Darling/Darling.Tests/PerfmonWaitStatisticsArtifactMcpLiveTests.cs @@ -7,6 +7,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -110,6 +111,103 @@ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) = } } + /// + /// #5562 L4b: the Wait Statistics arm's suffix test moved from right(object_name, 16) = ':Wait Statistics' to + /// object_name LIKE '%:Wait Statistics' so a compressed chunk's columnar scan can vectorize it. The two must pick the + /// same rows, so this runs the shipped SQL and the same SQL with the old predicate side by side over every object-name shape: + /// the default instance, a named instance (with its own isolated spike), another object, a NULL object, and near misses + /// (longer and shorter names) that share the suffix's start but not its end. + /// + [Fact] + public async Task PerfmonTrendSql_LikeSuffixPicksTheSameRowsAsTheOldRightPredicate_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live artifact round trip."); + + var ct = TestContext.Current.CancellationToken; + await using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var t0 = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-10); + + var objects = new (string? Name, long[] Values)[] + { + ("SQLServer:Wait Statistics", new long[] { 300, 310, 320, 330, 340 }), + ("MSSQL$INST1:Wait Statistics", new long[] { 200, 214_396_451, 220, 230, 240 }), + ("SQLServer:SQL Statistics", new long[] { 11, 12, 13, 14, 15 }), + (null, new long[] { 21, 22, 23, 24, 25 }), + ("SQLServer:Wait Statistics Extra", new long[] { 31, 32, 33, 34, 35 }), + ("Wait Statistics", new long[] { 41, 42, 43, 44, 45 }), + ("SQLServer:Wait Statistic", new long[] { 51, 52, 53, 54, 55 }), + }; + foreach (var (name, values) in objects) + { + for (var i = 0; i < values.Length; i++) + { + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds, cntr_type) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + CollectionIdGenerator.Next(), DarlingMcpTestData.Naive(t0.AddMinutes(i)), ServerId, ServerName, name, "Waits started per second", "", values[i], + (long?)null, (int?)null, PerfmonCounterTypes.PerfCounterLargeRawCount); + } + } + + var shipped = DarlingTrendReader.PerfmonTrendSql; + /* Both arms test the suffix with LIKE (the artifact predicate's own right() runs on the first arm's few surviving rows). */ + Assert.Equal(2, shipped.Split("object_name LIKE '%:Wait Statistics'").Length - 1); + var old = shipped.Replace("object_name LIKE '%:Wait Statistics'", "right(object_name, 16) = ':Wait Statistics'", StringComparison.Ordinal); + Assert.NotEqual(shipped, old); + + async Task> RunAsync(string sql) + { + var rows = new List(); + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue("Waits started per second"); + command.Parameters.AddWithValue(DarlingMcpTestData.Naive(t0.AddMinutes(-1))); + command.Parameters.AddWithValue(DarlingMcpTestData.Naive(t0.AddMinutes(10))); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var cells = new string[reader.FieldCount]; + for (var c = 0; c < cells.Length; c++) + { + cells[c] = reader.IsDBNull(c) ? "NULL" : Convert.ToString(reader.GetValue(c), System.Globalization.CultureInfo.InvariantCulture)!; + } + + rows.Add(string.Join("|", cells)); + } + + return rows; + } + + var withLike = await RunAsync(shipped); + var withRight = await RunAsync(old); + Assert.Equal(5, withLike.Count); + Assert.Equal(withRight, withLike); + + /* The named instance's spike is still set aside (the artifact count is the last column), and nothing else is. */ + Assert.Equal(1, withLike.Sum(r => long.Parse(r.Split('|')[^1], System.Globalization.CultureInfo.InvariantCulture))); + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + { + await DeleteRowsAsync(cleanup, cleanupCt); + using var servers = new NpgsqlCommand("DELETE FROM servers WHERE server_id = $1", cleanup); + servers.Parameters.AddWithValue(ServerId); + await servers.ExecuteNonQueryAsync(cleanupCt); + }); + } + } + private static Task PlantAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct, DateTime at, string counterName, long value, int type) => DarlingMcpTestData.ExecAsync(connection, ct, diff --git a/Darling/Darling.Tests/ServerPageTabsTests.cs b/Darling/Darling.Tests/ServerPageTabsTests.cs index 8af1c2f89..01f75b2ea 100644 --- a/Darling/Darling.Tests/ServerPageTabsTests.cs +++ b/Darling/Darling.Tests/ServerPageTabsTests.cs @@ -898,7 +898,7 @@ public void TheTabsThatCannotDoWhatTheDesktopDoes_SaySo() /* The note renders — a `note` field with no renderer is the same silence in a different place. */ Assert.Contains("return note ? noticeStrip(note) : null;", js, StringComparison.Ordinal); - Assert.Contains("tabNote(tab, WIDEST_RANGE_HOURS)", ServerJs, StringComparison.Ordinal); + Assert.Contains("tabNote(tab, tabReach())", ServerJs, StringComparison.Ordinal); } /// diff --git a/Darling/Darling.Tests/ServerPageTitlePinTests.cs b/Darling/Darling.Tests/ServerPageTitlePinTests.cs index a9d4a52dd..5af915d87 100644 --- a/Darling/Darling.Tests/ServerPageTitlePinTests.cs +++ b/Darling/Darling.Tests/ServerPageTitlePinTests.cs @@ -82,7 +82,7 @@ public void TheRouteKey_StaysTheIdentity_OnlyTheHeadingChanges() Assert.Contains("const matches = (c) => c.server_name === server || c.display_name === server;", js, StringComparison.Ordinal); Assert.Contains("current = { server, tab: null };", js, StringComparison.Ordinal); - Assert.Contains("mount(main, [head, whySlot, tabsSlot, gridNode]);", js, StringComparison.Ordinal); + Assert.Contains("mount(main, [head, whySlot, tabsSlot, reachSlot, gridNode]);", js, StringComparison.Ordinal); Assert.DoesNotContain("server = card.display_name", js, StringComparison.Ordinal); Assert.DoesNotContain("current = { server: card.display_name", js, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs b/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs index e7680367a..7a46f4b68 100644 --- a/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs +++ b/Darling/Darling.Tests/ServerTabToolbarAndTooltipsTests.cs @@ -53,7 +53,7 @@ public void ToolbarCombos_HaveANameForAScreenReader(string app) { var expected = new Dictionary { - ["TimeRangeCombo"] = "Time range", + ["RangePicker"] = "Time range", ["CompareToCombo"] = "Compare to", ["AutoRefreshIntervalCombo"] = "Auto-refresh interval", ["TimeDisplayModeBox"] = "Time display", diff --git a/Darling/Darling.Tests/TimeRangePickerControlTests.cs b/Darling/Darling.Tests/TimeRangePickerControlTests.cs new file mode 100644 index 000000000..bb2efac59 --- /dev/null +++ b/Darling/Darling.Tests/TimeRangePickerControlTests.cs @@ -0,0 +1,219 @@ +/* + * 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.Threading; +using System.Windows; +using System.Windows.Media; +using System.Windows.Threading; +using PerformanceMonitor.Ui; +using Xunit; + +/* Darling.Tests only: a Darling file Lite.Tests compiles must be named in build.yml's Lite path filter. */ +namespace Darling.Tests; + +/// #5562: the shared WPF time range picker constructs, renders and raises its event on an STA thread. +public sealed class TimeRangePickerControlTests +{ + private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + private static readonly DateTime Now = new(2026, 10, 8, 11, 1, 0, DateTimeKind.Unspecified); + + private static TimeRangePicker Build() + => new() { ZoneProvider = () => NewYork, NowProvider = () => Now }; + + [Fact] + public void Constructs_WithTheShortPresetsAndTheCalendarPeriods() + { + OnStaThread(() => + { + var picker = Build(); + Assert.Equal(9, picker.RollingPanel.Children.Count); + Assert.Equal(8, picker.PeriodPanel.Children.Count); + Assert.Equal("4h", picker.Value.Id); + Assert.Equal("Past 4 hours", picker.ButtonText.Text); + Assert.Equal("Oct 8, 3:01 am - Oct 8, 7:01 am (UTC-04:00)", picker.ResolvedText.Text); + Assert.False(picker.IsPopupOpen); + }); + } + + [Fact] + public void Value_RendersWithoutRaisingTheEvent_AndSelectRaisesIt() + { + OnStaThread(() => + { + var picker = Build(); + var raised = 0; + TimeRangeChangedEventArgs? last = null; + picker.RangeChanged += (_, e) => { raised++; last = e; }; + + picker.Value = TimeRangeSpec.ForPeriod(CalendarPeriod.WeekToDate); + Assert.Equal(0, raised); + Assert.Equal("Week to Date", picker.ButtonText.Text); + Assert.Equal("Oct 5, 12:00 am - Oct 8, 7:01 am (UTC-04:00)", picker.ResolvedText.Text); + + Assert.True(picker.Select(TimeRangePresets.Find("1w")!)); + Assert.Equal(1, raised); + Assert.Equal("1w", last!.Spec.Id); + Assert.Equal(TimeSpan.FromDays(7), last.Range.Span); + Assert.Equal("1w", picker.Value.Id); + }); + } + + [Fact] + public void Select_RefusesARangeUnderTheMinimum_AndHoldsTheOldOne() + { + OnStaThread(() => + { + var picker = Build(); + picker.NowProvider = () => new DateTime(2026, 10, 8, 4, 2, 0, DateTimeKind.Unspecified); + var raised = false; + picker.RangeChanged += (_, _) => raised = true; + + Assert.False(picker.Select(TimeRangeSpec.Relative(TimeSpan.FromMinutes(4)))); + Assert.False(raised); + Assert.Equal("4h", picker.Value.Id); + + /* A calendar period is exempt from the floor: Today at 00:02 is held, never replaced by another range. */ + Assert.True(picker.Select(TimeRangeSpec.ForPeriod(CalendarPeriod.Today))); + Assert.True(raised); + Assert.Equal("today", picker.Value.Id); + }); + } + + [Fact] + public void Resolve_FollowsTheZoneProvider() + { + OnStaThread(() => + { + var picker = Build(); + picker.Value = TimeRangeSpec.ForPeriod(CalendarPeriod.Yesterday); + var eastern = picker.Resolve()!; + picker.ZoneProvider = () => TimeZoneInfo.Utc; + picker.Refresh(); + var utc = picker.Resolve()!; + Assert.NotEqual(eastern.StartUtc, utc.StartUtc); + Assert.Equal("Oct 7, 12:00 am - Oct 8, 12:00 am (UTC+00:00)", picker.ResolvedText.Text); + }); + } + + [Fact] + public void Notes_ShowTheDataStartAndTheCollectorInterval() + { + OnStaThread(() => + { + var picker = Build(); + picker.Value = TimeRangePresets.Find("1w")!; + Assert.Equal(Visibility.Collapsed, picker.NoteText.Visibility); + + picker.DataStartUtc = new DateTime(2026, 10, 3, 18, 0, 0); + Assert.Equal(Visibility.Visible, picker.NoteText.Visibility); + Assert.Equal("Data starts Oct 3, 2:00 pm", picker.NoteText.Text); + + picker.Value = TimeRangePresets.Find("5m")!; + picker.DataStartUtc = null; + picker.SampleInterval = TimeSpan.FromMinutes(5); + Assert.Equal("Data here is collected every 5 minutes.", picker.NoteText.Text); + + picker.SampleInterval = TimeSpan.FromMinutes(1); + Assert.Equal(Visibility.Collapsed, picker.NoteText.Visibility); + }); + } + + [Fact] + public void Compact_HidesTheDetailBesideTheButton_AndKeepsItInTheTooltip() + { + OnStaThread(() => + { + var picker = Build(); + picker.Compact = true; + Assert.Equal(Visibility.Collapsed, picker.DetailPanel.Visibility); + Assert.Contains("(UTC-04:00)", (string)picker.PickerButton.ToolTip, StringComparison.Ordinal); + picker.Compact = false; + Assert.Equal(Visibility.Visible, picker.DetailPanel.Visibility); + }); + } + + [Fact] + public void TypingARange_PreviewsItBeforeApply_AndAnErrorDisablesApply() + { + OnStaThread(() => + { + var picker = Build(); + picker.InputBox.Text = "last month"; + Assert.True(picker.ApplyButton.IsEnabled); + Assert.Equal("30d Sep 1, 12:00 am - Oct 1, 12:00 am (UTC-04:00)", picker.EchoText.Text); + + picker.InputBox.Text = "3m"; + Assert.False(picker.ApplyButton.IsEnabled); + Assert.Contains("5 minutes", picker.EchoText.Text, StringComparison.Ordinal); + }); + } + + [Fact] + public void OpeningThePopup_ThemesTheCalendarFromTheHostBrushes_AndAPickedRangeFillsTheTextBox() + { + OnStaThread(() => + { + var dark = new SolidColorBrush(Color.FromRgb(0x11, 0x12, 0x17)); + var light = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + var picker = Build(); + picker.Resources["BackgroundBrush"] = dark; + picker.Resources["ForegroundBrush"] = light; + var window = new Window { Content = picker, Width = 300, Height = 200, ShowActivated = false, WindowStyle = WindowStyle.None, ShowInTaskbar = false, Left = -4000, Top = -4000 }; + try + { + window.Show(); + picker.PickerPopup.IsOpen = true; + // Let the Opened handler and its Loaded-priority theming pass run. + window.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle); + + Assert.Same(dark, picker.PickCalendar.Background); + Assert.Same(light, picker.PickCalendar.Foreground); + Assert.Equal("Examples:", picker.EchoText.Text.Substring(0, 9)); + Assert.NotEmpty(picker.PeriodPanel.Children); + + picker.PickCalendar.SelectedDates.AddRange(new DateTime(2026, 10, 1), new DateTime(2026, 10, 3)); + Assert.Equal("Oct 1 - Oct 3", picker.InputBox.Text); + Assert.True(picker.ApplyButton.IsEnabled); + + TimeRangeChangedEventArgs? picked = null; + picker.RangeChanged += (_, e) => picked = e; + picker.ApplyButton.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ButtonBase.ClickEvent)); + Assert.NotNull(picked); + Assert.Equal(TimeRangeKind.Fixed, picked!.Spec.Kind); + Assert.Equal(new DateTime(2026, 10, 1, 4, 0, 0), picked.Range.StartUtc); + Assert.Equal(new DateTime(2026, 10, 4, 4, 0, 0), picked.Range.EndUtc); + Assert.False(picker.IsPopupOpen); + } + finally + { + window.Close(); + } + }); + } + + /// WPF objects require STA; same shape as AvailabilityGroupsTabRefreshTests. + private static void OnStaThread(Action body) + { + Exception? error = null; + using var staGate = WpfStaGate.Enter(); + var thread = new Thread(() => + { + try { body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error is not null) + { + throw error; + } + } +} diff --git a/Darling/Darling.Tests/TimeRangeTests.cs b/Darling/Darling.Tests/TimeRangeTests.cs new file mode 100644 index 000000000..eb77bb62a --- /dev/null +++ b/Darling/Darling.Tests/TimeRangeTests.cs @@ -0,0 +1,332 @@ +/* + * 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; + +/* Darling.Tests covers the shared picker model (Lite and the Viewer both reference PerformanceMonitor.Ui). + Lite.Tests does not compile this file: a linked Darling file must be named in build.yml's Lite path filter. */ +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)); + if (c.TryGetProperty("errorMessage", out var message)) + { + // A row that states its words holds both front ends to them (the midnight refusal of a calendar period). + Assert.Equal(message.GetString(), 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 exempt from the 5-minute floor (seat ruling): it reads 00:00 to now, never another range. + Assert.Equal("2m", TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.Today), new DateTime(2026, 10, 8, 4, 2, 0), NewYork)); + // Exactly at midnight nothing has happened yet, so a zero-length period is still refused. + Assert.Null(TimeRangePresets.CurrentLength(TimeRangeSpec.ForPeriod(CalendarPeriod.Today), new DateTime(2026, 10, 8, 4, 0, 0), NewYork)); + } + + [Theory] + [InlineData("400000mo")] + [InlineData("1000000d")] + [InlineData("999999w")] + [InlineData("1000000h")] + public void AnId_PastTheHundredYearLimit_IsNotAnIdAndNeverThrows(string id) + { + // #5562 review H1: a hand-edited settings value used to overflow TimeSpan and crash startup. + Assert.False(TimeRangeSpec.TryFromId(id, out var spec)); + Assert.Null(spec); + } + + [Fact] + public void AnId_AtTheLimit_StillParses() + { + Assert.True(TimeRangeSpec.TryFromId("36500d", out var spec)); + Assert.Equal(TimeSpan.FromDays(36500), spec!.Span); + Assert.False(TimeRangeSpec.TryFromId("36501d", out _)); + } + + [Theory] + [InlineData(int.MaxValue)] + [InlineData(300_000_000)] + [InlineData(876_001)] + public void LegacyHours_PastTheLimit_AreNull_NotAnOverflow(int hours) + { + Assert.Null(TimeRangePresets.FromLegacyHours(hours)); + } + + [Fact] + public void ACalendarPeriodIsExemptFromTheFloor_ButARelativeOrFixedRangeIsNot() + { + var justAfterMidnight = new DateTime(2026, 10, 8, 4, 2, 0); + Assert.True(TimeRangeSpec.ForPeriod(CalendarPeriod.Today).TryResolve(justAfterMidnight, NewYork, out var today, out _)); + Assert.Equal(new DateTime(2026, 10, 8, 4, 0, 0), today!.StartUtc); + Assert.Equal(justAfterMidnight, today.EndUtc); + Assert.False(TimeRangeSpec.FixedRange(justAfterMidnight.AddMinutes(-2), justAfterMidnight).TryResolve(justAfterMidnight, NewYork, out _, out var error)); + Assert.Equal("too_short", error!.Code); + } + + [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/Darling/Darling.Tests/TsqlConventionGuardTests.cs b/Darling/Darling.Tests/TsqlConventionGuardTests.cs index c341d49ca..186aaa3b8 100644 --- a/Darling/Darling.Tests/TsqlConventionGuardTests.cs +++ b/Darling/Darling.Tests/TsqlConventionGuardTests.cs @@ -1543,6 +1543,11 @@ Lite twin (LocalDataService.FinOps.IndexObjects.cs, below) derives the same four newest > TimeSpan.FromDays(ArchiveService.HotDataDays)`, an age comparison on the newest snapshot time, not T-SQL and not a tempdb label, so no census reads a site of that kind here. */ "Lite/Services/LocalDataService.DatabaseStates.cs IsStale", + /* #5562: an expression-bodied method whose body opens with a property pattern (`untilUtc is { } end ? … : …`), the + same shape as IsStale above. The brace match closes the range at the pattern's own `}`, stranding the two calls to + ReadAlertHistoryAsync. They hold no string literal and no T-SQL (the SQL is in ReadAlertHistoryAsync, which the scan + reads whole), so no census reads a site of that kind outside the range. */ + "Lite/Services/LocalDataService.AlertHistory.cs GetAlertHistoryWindowAsync", /* #3541 A12: the Lite twin of the four DarlingObjectStatsReader growth derivations above — the same `is { } b ? … : null` shape, the same absence of any string literal. */ "Lite/Services/LocalDataService.FinOps.IndexObjects.cs DailyGrowthRateMb", diff --git a/Darling/Darling.Tests/ViewerAlertClockCacheTests.cs b/Darling/Darling.Tests/ViewerAlertClockCacheTests.cs index 33e1cb676..453d3121a 100644 --- a/Darling/Darling.Tests/ViewerAlertClockCacheTests.cs +++ b/Darling/Darling.Tests/ViewerAlertClockCacheTests.cs @@ -142,7 +142,7 @@ public void TheAlertHistoryReadTakesItsClocksFromTheCache_NotFromTheStoreEveryCa var alertHistory = ViewerCode("ViewerDataService.AlertHistory.cs"); var method = Regex.Match( alertHistory, - @"public async Task> GetAlertHistoryAsync\(.*?\n \}\n", + @"public async Task> GetAlertHistoryWindowAsync\(.*?\n \}\n", RegexOptions.Singleline); Assert.True(method.Success); diff --git a/Darling/Darling.Tests/ViewerApplyToAllClockTests.cs b/Darling/Darling.Tests/ViewerApplyToAllClockTests.cs index f2e8583f5..806c1d039 100644 --- a/Darling/Darling.Tests/ViewerApplyToAllClockTests.cs +++ b/Darling/Darling.Tests/ViewerApplyToAllClockTests.cs @@ -61,7 +61,7 @@ public void ThePlaceThatBroadcastsTheRange_SkipsOnlyTheSource_AndASelectedTabRel var selection = Member("MainWindow.xaml.cs", "MainTabs_SelectionChanged"); var load = Member("MainWindow.xaml.cs", "LoadVisibleTabAsync"); - Assert.Contains("ApplyExternalTimeRange(index, customFromUtc, customToUtc)", broadcast); + Assert.Contains("ApplyExternalTimeRange(range)", broadcast); Assert.Contains("!ReferenceEquals(serverTab, source)", broadcast); Assert.Contains("await RefreshVisibleAsync()", selection); Assert.Matches(@"case\s+TabItem\s*\{\s*Content:\s*ViewerServerTab\s+serverTab\s*\}\s*:\s*await\s+serverTab\.RefreshActiveInnerTabAsync\(\)", load); diff --git a/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs b/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs index 66aa875ff..de7350970 100644 --- a/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs +++ b/Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs @@ -194,7 +194,7 @@ public static async Task CreateAsync(CancellationToken ct) public async Task NoteAsync(int? serverId, CancellationToken ct) { var probe = Viewer.GetJobHistoryDataStartAsync(serverId, Start, End, ct); - var read = await Viewer.GetJobHistoryAsync(Start, serverId, JobHistoryTab.RowCap, ct); + var read = await Viewer.GetJobHistoryAsync(Start, serverId, JobHistoryTab.RowCap, cancellationToken: ct); await probe.WaitAsync(ct); string? text = null; diff --git a/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs b/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs index ba340e3b2..380796678 100644 --- a/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs +++ b/Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs @@ -117,11 +117,14 @@ public void TheTab_WorksOutTheWindowsStartOnce_AndHandsItToTheReadAndTheProbe() { var load = MethodBody(ViewerFile("JobHistoryTab.xaml.cs"), @"private async Task LoadJobsAsync\("); - Assert.Equal(1, Matches(load, @"var nowUtc = DateTime\.UtcNow;")); - Assert.Equal(1, Matches(load, @"var sinceUtc = nowUtc\.AddHours\(-hoursBack\);")); + /* #5562: the window comes from the picker once; its start and end are the ones the read and the probe take. */ + Assert.Equal(1, Matches(load, @"var nowUtc = windowEndUtc;")); + Assert.Equal(1, Matches(load, @"var sinceUtc = windowStartUtc;")); + Assert.Equal(1, Matches(load, @"ViewerTimeRangeWindow\.Window\(")); Assert.Equal(0, Matches(load, @"DateTime\.UtcNow\.AddHours")); Assert.Equal(1, Matches(load, @"var dataStartTask = _dataService\.GetJobHistoryDataStartAsync\(serverId,\s*sinceUtc,\s*nowUtc\);")); - Assert.Equal(1, Matches(load, @"var readTask = _dataService\.GetJobHistoryAsync\(sinceUtc,\s*serverId,\s*RowCap\)")); + /* #5562 R6: a finished range hands its end to the read, so the end bound lands before the row cap. */ + Assert.Equal(1, Matches(load, @"var readTask = _dataService\.GetJobHistoryAsync\(sinceUtc,\s*serverId,\s*RowCap,\s*untilUtc:\s*windowIsLive \? null : windowEndUtc\)")); /* The probe starts beside the read, not after it. */ Assert.True( load.IndexOf("GetJobHistoryDataStartAsync(", StringComparison.Ordinal) < load.IndexOf("var readTask = _dataService.GetJobHistoryAsync(", StringComparison.Ordinal), diff --git a/Darling/Darling.Tests/ViewerTimeRangeGapFixTests.cs b/Darling/Darling.Tests/ViewerTimeRangeGapFixTests.cs new file mode 100644 index 000000000..510572990 --- /dev/null +++ b/Darling/Darling.Tests/ViewerTimeRangeGapFixTests.cs @@ -0,0 +1,372 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Controls; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +/* Darling.Tests only: a Darling file Lite.Tests compiles must be named in build.yml's Lite path filter. */ +namespace Darling.Tests; + +/// +/// #5562, the four gaps the first Viewer pass left. R5: a FinOps picker offers only a range its "hours back from now" read can +/// honor. R6: Job History's finished range is bounded in the SQL, before the row cap. R7: every "Showing since" banner site feeds +/// the picker's data-start note. R8: Alert History's longest choice is the span its table keeps. +/// +[Collection("live-postgres")] +public sealed class ViewerTimeRangeGapFixTests +{ + private static string ViewerFile(string file) => RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", file); + + private static string Code(string file) => ViewerTypedRangeTests.StripComments(ViewerFile(file)); + + private static TimeRangeSpec Spec(string id) + { + Assert.True(TimeRangeSpec.TryFromId(id, out var spec), id); + return spec!; + } + + // ── R5: FinOps pickers are rolling-only, in whole hours (the heatmap in whole days) ── + + [Theory] + [InlineData("1h", true)] + [InlineData("4h", true)] + [InlineData("1d", true)] + [InlineData("1w", true)] + [InlineData("1mo", true)] + [InlineData("90m", false)] + [InlineData("30m", false)] + [InlineData("5m", false)] + [InlineData("today", false)] + [InlineData("previous-week", false)] + [InlineData("since:2026-10-01T04:00:00Z", false)] + [InlineData("fixed:2026-10-01T04:00:00Z/2026-10-02T04:00:00Z", false)] + public void TheHourRule_AdmitsOnlyAWholeNumberOfHoursBackFromNow(string id, bool admitted) + { + var spec = id == "90m" ? TimeRangeSpec.Relative(TimeSpan.FromMinutes(90)) : Spec(id); + Assert.Equal(admitted, RollingUnitRule.Admits(spec, RollingUnitRule.Hour)); + Assert.Equal(admitted, RollingUnitRule.Refusal(spec, RollingUnitRule.Hour) is null); + } + + [Theory] + [InlineData("1d", true)] + [InlineData("2d", true)] + [InlineData("1mo", true)] + [InlineData("1h", false)] + [InlineData("4h", false)] + public void TheDayRule_AdmitsOnlyAWholeNumberOfDaysBackFromNow(string id, bool admitted) + { + var spec = Spec(id); + Assert.Equal(admitted, RollingUnitRule.Admits(spec, RollingUnitRule.Day)); + Assert.False(RollingUnitRule.Admits(TimeRangeSpec.Relative(TimeSpan.FromHours(36)), RollingUnitRule.Day)); + Assert.False(RollingUnitRule.Admits(TimeRangeSpec.ForPeriod(CalendarPeriod.MonthToDate), RollingUnitRule.Day)); + } + + [Fact] + public void TheRefusal_SaysWhatTheReadTakes() + { + Assert.Equal("The shortest range for this read is 1 hour.", RollingUnitRule.Refusal(TimeRangeSpec.Relative(TimeSpan.FromMinutes(30)), RollingUnitRule.Hour)); + Assert.Equal("This read takes whole hours; try 2h.", RollingUnitRule.Refusal(TimeRangeSpec.Relative(TimeSpan.FromMinutes(90)), RollingUnitRule.Hour)); + Assert.Contains("length back from now", RollingUnitRule.Refusal(TimeRangeSpec.ForPeriod(CalendarPeriod.Today), RollingUnitRule.Hour), StringComparison.Ordinal); + Assert.Equal("The shortest range for this read is 1 day.", RollingUnitRule.Refusal(TimeRangeSpec.Relative(TimeSpan.FromHours(4)), RollingUnitRule.Day)); + } + + [Fact] + public void ARollingOnlyPicker_RefusesWhatItsReadCannotHonor_AndHidesTheCalendar() + { + OnStaThread(() => + { + var now = new DateTime(2026, 10, 8, 11, 1, 0, DateTimeKind.Unspecified); + var picker = new TimeRangePicker { ZoneProvider = () => TimeZoneInfo.Utc, NowProvider = () => now, Compact = true }; + picker.RollingUnit = RollingUnitRule.Hour; + var raised = 0; + picker.RangeChanged += (_, _) => raised++; + + Assert.Equal(System.Windows.Visibility.Collapsed, picker.PeriodPanel.Visibility); + Assert.Equal(System.Windows.Visibility.Collapsed, picker.PickCalendar.Visibility); + Assert.Equal(System.Windows.Visibility.Collapsed, picker.CalendarHeader.Visibility); + + /* Presets under an hour are greyed; the others stay. */ + var enabled = picker.RollingPanel.Children.OfType