From 0ebc4e439fcb04f23ce11e6cf967ad34d9b58b7a Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:50:40 +0200 Subject: [PATCH 1/3] Show remaining usage consistently and improve local testing --- .github/skills/publish-extension/SKILL.md | 2 +- .github/workflows/release.yml | 5 + AGENTS.md | 2 +- CHANGELOG.md | 15 ++ CodexUsageDock.Tests/CodexUsageTableTests.cs | 42 +++ .../NavigationContractTests.cs | 93 +++++++ CodexUsageDock.Tests/UsageDashboardTests.cs | 168 +++++++++--- CodexUsageDock.Tests/UsageDataTests.cs | 55 ++-- CodexUsageDock.Tests/UsageFreshnessTests.cs | 13 +- .../CodexUsageDockCommandsProvider.cs | 5 +- CodexUsageDock/Pages/CodexUsageDockPage.cs | 252 ++++++------------ CodexUsageDock/Pages/CodexUsageTablePage.cs | 53 ++-- CodexUsageDock/Pages/NavigationTestPage.cs | 92 +++++++ CodexUsageDock/Pages/UsageDashboardCard.cs | 182 ++++++------- CodexUsageDock/UsageQuotaGroups.cs | 49 ++++ CodexUsageDock/UsageTrendAnalyzer.cs | 6 +- CodexUsageDock/WeeklyUsageTrendChart.cs | 44 ++- DEVELOPMENT.md | 48 +++- README.md | 8 +- USER_GUIDE.md | 16 +- docs/navigation-reproduction.md | 64 +++++ scripts/local-test-policy.ps1 | 11 + scripts/test-integration.ps1 | 27 +- scripts/test-local.ps1 | 177 ++++++++++++ scripts/tests/test-local.Tests.ps1 | 161 +++++++++++ 25 files changed, 1185 insertions(+), 405 deletions(-) create mode 100644 CodexUsageDock.Tests/NavigationContractTests.cs create mode 100644 CodexUsageDock/Pages/NavigationTestPage.cs create mode 100644 CodexUsageDock/UsageQuotaGroups.cs create mode 100644 docs/navigation-reproduction.md create mode 100644 scripts/local-test-policy.ps1 create mode 100644 scripts/test-local.ps1 create mode 100644 scripts/tests/test-local.Tests.ps1 diff --git a/.github/skills/publish-extension/SKILL.md b/.github/skills/publish-extension/SKILL.md index a41ce7c..1a7effe 100644 --- a/.github/skills/publish-extension/SKILL.md +++ b/.github/skills/publish-extension/SKILL.md @@ -27,7 +27,7 @@ Use [DEVELOPMENT.md](../../../DEVELOPMENT.md) as the canonical source for comman ## Invariants and completion -- Microsoft Store is the production and update channel. Do not install unsigned Actions artifacts, introduce self-signing, or replace a production Store registration with a development build. +- Microsoft Store is the production and update channel. Do not install unsigned Actions artifacts or introduce self-signing. Replacing a Store registration for explicitly requested local testing uses the backed-up `scripts/test-local.ps1` workflow in `DEVELOPMENT.md`; it is not a production release. - Preserve the assigned Store identity, COM registration, capabilities, self-contained x64/ARM64 layout, and older releases. A change to these needs explicit task scope. - Prefer the existing GitHub CLI/workflow and package scripts for deterministic operations; use the supported browser for Partner Center when no suitable API is configured. Follow repository escalation instructions. Never store credentials in Git or release records. - Distinguish **committed**, **pushed/merged**, **package validated**, **submitted**, **certified**, **published**, **installed**, and **functionally verified**. Claim each only with evidence. A successful build does not prove installation; an old public Store listing does not prove the new version is available. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3297a98..e66e869 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,11 @@ jobs: run: dotnet restore ./CodexUsageDock.Tests/CodexUsageDock.Tests.csproj -p:Platform=x64 -r win-x64 -p:SelfContained=true - name: Test run: dotnet test ./CodexUsageDock.Tests/CodexUsageDock.Tests.csproj -c Debug -p:Platform=x64 -r win-x64 -p:SelfContained=true --no-restore + - name: Test local installation workflow + shell: powershell + run: | + Import-Module Pester -RequiredVersion 3.4.0 + Invoke-Pester ./scripts/tests/test-local.Tests.ps1 -EnableExit - name: Build x64 Debug run: dotnet build ./CodexUsageDock/CodexUsageDock.csproj -c Debug -p:Platform=x64 -r win-x64 --self-contained true - name: Build ARM64 Debug diff --git a/AGENTS.md b/AGENTS.md index 23230cd..86cba04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Use the [setup instructions](DEVELOPMENT.md#prerequisites) and [verification com For changes affecting registration or Command Palette behavior: 1. After building, run `scripts/test-integration.ps1` with the matching `-Architecture` and without `-Register`. This checks build outputs and existing registration without changing installation. -2. Registration is a separate installation action: use `-Register` only within authorized scope and in the isolated environment required by [DEVELOPMENT.md](DEVELOPMENT.md#integration-smoke-test). It can close the running extension; never replace a production Store installation with a development package. A missing registration is not permission to install. +2. Registration is a separate installation action: use `-Register` only within authorized scope. Prefer an isolated environment. When the user explicitly requests testing in their current account, use the backed-up `scripts/test-local.ps1` workflow in [DEVELOPMENT.md](DEVELOPMENT.md#test-in-your-current-windows-account); it may replace the Store registration and closes the running extension. A missing registration is not permission to install. 3. For authorized integration work, complete **Reload Command Palette Extension** and the applicable functional checks in DEVELOPMENT.md. Report preflight warnings and unavailable UI checks explicitly. A passing build or preflight does not establish successful COM activation or working UI behavior. ## Security and Privacy diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a32927..c9ce23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ## [Unreleased] +### Changed + +- Show remaining allowance in dashboard bars and the weekly chart, restore daily token bars and their right-hand axis by default, and stop forecasts at the estimated limit. Prioritize weekly status and the chart, use full-width single windows, improve column separation and chart labels, and replace the Details toggle with Usage information in the More menu. +- Compact dashboard headings and spacing, match single-window bar thickness to paired windows, and shorten Usage information. Use readable text rows with shared quota names and consistent English local dates in the text alternative. + +### Fixed + +- Detect Smart App Control enforcement before unsigned local-test installation or registration, preserving the current installation and explaining signed Store recovery. +- Deduplicate default quotas regardless of their API position, avoid narrow token-table cells that split full numbers, omit empty forecast sections, and use the same rounded forecast labels throughout the dashboard, chart, and information page. + +### Added + +- Add a Debug-only navigation test page, automated action-contract checks, and a repeatable host UI procedure for disappearing footer actions after Back. +- Add a one-command local test workflow for the current Windows account, with data backups, validated development registration, and a command to return to Microsoft Store. + ## [0.8.1] - 2026-09-15 ### Fixed diff --git a/CodexUsageDock.Tests/CodexUsageTableTests.cs b/CodexUsageDock.Tests/CodexUsageTableTests.cs index 2ebf050..1f1f187 100644 --- a/CodexUsageDock.Tests/CodexUsageTableTests.cs +++ b/CodexUsageDock.Tests/CodexUsageTableTests.cs @@ -4,6 +4,48 @@ namespace CodexUsageDock.Tests; public sealed class CodexUsageTableTests { + [Fact] + public void RemappedWeeklyQuotaAppearsOnceAndOtherCategoriesStaySeparate() + { + var now = new DateTimeOffset(2026, 9, 16, 0, 0, 0, TimeSpan.Zero); + var weekly = new RateLimitWindow(49, 10080, now.AddDays(4)); + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = null, Secondary = weekly, UpdatedAt = now, Source = UsageDataSource.AppServer, + ResetCredits = new(0, null), + DefaultBucketId = "codex", + Buckets = [new("codex", "codex", weekly, null), new("spark", "Spark", weekly, null)], + }; + var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1)); + var groups = UsageQuotaGroups.Create(snapshot); + Assert.Equal(2, groups.Count); + Assert.Single(groups[0].Windows, item => item.Window == weekly); + Assert.Single(groups[1].Windows); + Assert.Equal(2, body.Split("**Weekly:** 51% remaining", StringSplitOptions.None).Length - 1); + Assert.Equal(1, body.Split("Not reported", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("primary", body, StringComparison.Ordinal); + Assert.DoesNotContain("10080", body, StringComparison.Ordinal); + } + + [Fact] + public void FullTokenTotalsAndLocalDatesUseUnconstrainedTextRows() + { + var now = new DateTimeOffset(2026, 9, 15, 22, 1, 0, TimeSpan.Zero); + var timeZone = TimeZoneInfo.CreateCustomTimeZone("Test local", TimeSpan.FromHours(2), "Test local", "Test local"); + var snapshot = CodexUsageSnapshot.Loading with { UpdatedAt = now, Source = UsageDataSource.AppServer }; + var tokens = new LocalTokenUsageSnapshot([new(new DateOnly(2026, 9, 13), 51_732_933), + new(new DateOnly(2026, 9, 15), 9_999_999_999)], now, LocalTokenUsageStatus.Complete); + var view = new UsagePresentation(snapshot, [new(now, 51)], [new(now, 51)], new([], null), tokens, false); + var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1), timeZone); + Assert.Contains("Observed: Wed 16 Sep 2026 00:01", body, StringComparison.Ordinal); + Assert.Contains("Times are local (Test local)", body, StringComparison.Ordinal); + Assert.Contains("- Sun 13 Sep 2026: **51,732,933** tokens", body, StringComparison.Ordinal); + Assert.Contains("- Tue 15 Sep 2026: **9,999,999,999** tokens", body, StringComparison.Ordinal); + Assert.DoesNotContain("| ---", body, StringComparison.Ordinal); + Assert.DoesNotContain("UTC", body, StringComparison.Ordinal); + } + [Fact] public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() { diff --git a/CodexUsageDock.Tests/NavigationContractTests.cs b/CodexUsageDock.Tests/NavigationContractTests.cs new file mode 100644 index 0000000..ad3b68d --- /dev/null +++ b/CodexUsageDock.Tests/NavigationContractTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +// Contract checks only: the host Back stack and command bar require the manual UI test. +public sealed class NavigationContractTests +{ + private static readonly string[] DashboardActionTitles = ["Refresh now", "Codex Usage settings", "Usage information", "Read usage in text"]; +#if DEBUG + private static readonly string[] FixtureChildNames = ["Settings", "Usage information", "Read usage in text"]; +#endif + + [Theory] + [InlineData("nl.mathijs.codexusage.settings")] + [InlineData("nl.mathijs.codexusage.information")] + [InlineData("nl.mathijs.codexusage.table")] + public void DashboardActionsSurviveChildContentReadsAndPresentationRefreshes(string childId) + { + using var environment = new TestEnvironment(); + using var service = environment.CreateService(); + using var provider = new CodexUsageDockCommandsProvider(service, environment.CreateSettings(), _ => { }); + var dashboard = Assert.IsType(provider.GetCommandItem("nl.mathijs.codexusage.details")!.Command); + var originalActions = dashboard.Commands.ToArray(); + var originalTargets = originalActions.Cast().Select(item => Assert.IsAssignableFrom(item.Command)).ToArray(); + Assert.Equal(DashboardActionTitles, + originalActions.Cast().Select(item => item.Title)); + var child = Assert.IsAssignableFrom(originalTargets.Single(command => command.Id == childId)); + + for (var cycle = 0; cycle < 3; cycle++) + { + Assert.NotEmpty(child.GetContent()); + dashboard.Refresh(); + Assert.NotEmpty(dashboard.GetContent()); + Assert.Equal(originalActions.Length, dashboard.Commands.Length); + for (var index = 0; index < originalActions.Length; index++) + { + Assert.Same(originalActions[index], dashboard.Commands[index]); + Assert.Same(originalTargets[index], Assert.IsType(dashboard.Commands[index]).Command); + } + } + } + + [Fact] + public void NavigationFixtureIsAvailableOnlyInDebugBuilds() + { + using var environment = new TestEnvironment(); + using var service = environment.CreateService(); + using var provider = new CodexUsageDockCommandsProvider(service, environment.CreateSettings(), _ => { }); + const string id = "nl.mathijs.codexusage.navigation-test"; +#if DEBUG + var item = Assert.Single(provider.TopLevelCommands(), item => item.Command.Id == id); + Assert.Same(item, provider.GetCommandItem(id)); + Assert.IsType(item.Command); +#else + Assert.Null(provider.GetCommandItem(id)); + Assert.DoesNotContain(provider.TopLevelCommands(), item => item.Command.Id == id); + Assert.Null(typeof(CodexUsageDockCommandsProvider).Assembly.GetType("CodexUsageDock.NavigationTestPage")); +#endif + } + +#if DEBUG + [Fact] + public void FixtureUsesProductionPageShapesAndRetainsActionsDuringRefresh() + { + var dashboard = new NavigationTestPage(); + var form = Assert.IsType(Assert.Single(dashboard.GetContent())); + var original = dashboard.Commands.ToArray(); + var children = original.Skip(1).Cast() + .Select(item => Assert.IsType(item.Command)).ToArray(); + Assert.Equal(FixtureChildNames, children.Select(child => child.Name)); + Assert.IsType(Assert.Single(children[0].GetContent())); + Assert.Single(children[0].Commands); + Assert.IsType(Assert.Single(children[1].GetContent())); + Assert.Empty(children[1].Commands); + Assert.IsType(Assert.Single(children[2].GetContent())); + Assert.Equal(2, children[2].Commands.Length); + + var refresh = Assert.IsType(Assert.IsType(original[0]).Command); + for (var cycle = 1; cycle <= 3; cycle++) + { + refresh.Invoke(); + using var data = JsonDocument.Parse(form.DataJson); + Assert.Equal(cycle, data.RootElement.GetProperty("refreshCount").GetInt32()); + Assert.Same(form, Assert.Single(dashboard.GetContent())); + for (var index = 0; index < original.Length; index++) + Assert.Same(original[index], dashboard.Commands[index]); + } + } +#endif +} diff --git a/CodexUsageDock.Tests/UsageDashboardTests.cs b/CodexUsageDock.Tests/UsageDashboardTests.cs index ab04856..13ef6b9 100644 --- a/CodexUsageDock.Tests/UsageDashboardTests.cs +++ b/CodexUsageDock.Tests/UsageDashboardTests.cs @@ -24,18 +24,17 @@ private static CodexUsageSnapshot Snapshot() => CodexUsageSnapshot.Loading with DefaultBucketId = "codex", }; - private static JsonDocument Render(CodexUsageSnapshot snapshot, bool details = false, UsageHistoryEntry[]? history = null) => + private static JsonDocument Render(CodexUsageSnapshot snapshot, UsageHistoryEntry[]? history = null) => JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now, false, [], history ?? [], TimeSpan.FromMinutes(1), tokenUsage: new LocalTokenUsageSnapshot([new DailyTokenUsage(new DateOnly(2026, 9, 15), 1000)], - Now, LocalTokenUsageStatus.Complete), - showDetails: details)); + Now, LocalTokenUsageStatus.Complete))); private static XDocument Chart(JsonElement data) => XDocument.Parse( Uri.UnescapeDataString(data.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); [Fact] - public void CodexAndSparkShareUsedAndTimeBarsWithoutCombiningCategories() + public void CodexAndSparkShareRemainingAndTimeBarsWithoutCombiningCategories() { var snapshot = Snapshot(); snapshot = snapshot with @@ -56,13 +55,13 @@ public void CodexAndSparkShareUsedAndTimeBarsWithoutCombiningCategories() var spark = groups[1].GetProperty("windows"); Assert.Equal(2, codex.GetArrayLength()); Assert.Equal(2, spark.GetArrayLength()); - Assert.Equal("25%", codex[0].GetProperty("usedPercent").GetString()); + Assert.Equal("75%", codex[0].GetProperty("remainingPercent").GetString()); Assert.Equal("20%", spark[0].GetProperty("elapsedPercent").GetString()); - Assert.Equal(codex[0].GetProperty("usedBarUrl").GetString(), spark[0].GetProperty("usedBarUrl").GetString()); + Assert.Equal(codex[0].GetProperty("remainingBarUrl").GetString(), spark[0].GetProperty("remainingBarUrl").GetString()); Assert.Equal(codex[0].GetProperty("elapsedBarUrl").GetString(), spark[0].GetProperty("elapsedBarUrl").GetString()); - Assert.Equal("33%", codex[1].GetProperty("usedPercent").GetString()); - Assert.Equal("80%", spark[1].GetProperty("usedPercent").GetString()); - Assert.DoesNotContain("available", codex[0].GetProperty("usedBarAlt").GetString()!, StringComparison.Ordinal); + Assert.Equal("67%", codex[1].GetProperty("remainingPercent").GetString()); + Assert.Equal("20%", spark[1].GetProperty("remainingPercent").GetString()); + Assert.Contains("75% remaining", codex[0].GetProperty("remainingBarAlt").GetString()!, StringComparison.Ordinal); } [Fact] @@ -111,7 +110,7 @@ public void MissingExpiredAndReportedZeroRemainDistinct() var groups = data.RootElement.GetProperty("quotaGroups"); Assert.Contains("Not reported", groups[0].GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); var zero = Assert.Single(groups[0].GetProperty("windows").EnumerateArray()); - Assert.Equal("0%", zero.GetProperty("usedPercent").GetString()); + Assert.Equal("100%", zero.GetProperty("remainingPercent").GetString()); Assert.Equal("0%", zero.GetProperty("elapsedPercent").GetString()); Assert.Empty(groups[1].GetProperty("windows").EnumerateArray()); Assert.Contains("Awaiting refresh", groups[1].GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); @@ -145,7 +144,7 @@ public void OldObservationsCannotLookLikeCurrentTimeComparisons(bool lastConfirm Assert.Equal("—", window.GetProperty("elapsedPercent").GetString()); Assert.False(window.GetProperty("elapsedAvailable").GetBoolean()); Assert.Equal(string.Empty, window.GetProperty("elapsedBarUrl").GetString()); - Assert.Contains("last observation", window.GetProperty("usedBarAlt").GetString(), StringComparison.Ordinal); + Assert.Contains("last observation", window.GetProperty("remainingBarAlt").GetString(), StringComparison.Ordinal); } Assert.Contains("paused", data.RootElement.GetProperty("weeklyForecastSummary").GetString(), StringComparison.Ordinal); } @@ -164,55 +163,150 @@ public void OldHistoryDoesNotInflateTheContinuousForecastProgress() } [Fact] - public void DefaultChartRisesWithUsageAndDetailsOnlyAddsTheTokenOverlay() + public void DefaultChartFallsWithUsageAndIncludesTokensOnTheRightAxis() { UsageHistoryEntry[] history = [new(Now.AddMinutes(-5), 80), new(Now, 67)]; using var compact = Render(Snapshot(), history: history); - using var expanded = Render(Snapshot(), details: true, history: history); var chart = Chart(compact.RootElement); - var detailChart = Chart(expanded.RootElement); - Assert.DoesNotContain(chart.Descendants(Svg + "rect"), node => node.Attribute("data-series")?.Value == "daily-tokens"); - Assert.Single(detailChart.Descendants(Svg + "rect"), node => node.Attribute("data-series")?.Value == "daily-tokens"); + Assert.Single(chart.Descendants(Svg + "rect"), node => node.Attribute("data-series")?.Value == "daily-tokens"); + Assert.Contains(chart.Descendants(Svg + "g"), node => node.Attribute("data-axis")?.Value == "tokens"); var line = Assert.Single(chart.Descendants(Svg + "polyline")); var points = line.Attribute("points")!.Value.Split(' '); var firstY = double.Parse(points[0].Split(',')[1], CultureInfo.InvariantCulture); var lastY = double.Parse(points[^1].Split(',')[1], CultureInfo.InvariantCulture); - Assert.True(lastY < firstY, "Increasing usage must move up the used-quota axis."); - Assert.Equal(line.Attribute("points")!.Value, Assert.Single(detailChart.Descendants(Svg + "polyline")).Attribute("points")!.Value); - Assert.Contains("20% to 33%", compact.RootElement.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.True(lastY > firstY, "Increasing usage must move down the remaining-quota axis."); + Assert.Contains("80% to 67%", compact.RootElement.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.Contains(chart.Descendants(Svg + "g"), node => node.Attribute("data-axis-label")?.Value == "67%"); Assert.NotEmpty(chart.Descendants(Svg + "path")); } [Fact] - public void DetailsToggleSurvivesRefreshAndClosesAgain() + public void InformationIsAvailableThroughARegularCommandWithoutAFormAction() { using var service = _environment.CreateService(); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent())); Assert.Null(page.Details); - form.SubmitForm("""{"action":"details"}"""); - var details = Assert.IsType
(page.Details); - Assert.Contains("Forecast and budget", details.Body, StringComparison.Ordinal); + var command = Assert.Single(page.Commands.OfType(), item => item.Title == "Usage information"); + var information = Assert.IsType(command.Command); + Assert.Contains("Forecast and budget", Assert.IsType(Assert.Single(information.GetContent())).Body, StringComparison.Ordinal); page.Refresh(); - Assert.Same(details, page.Details); - using (var data = JsonDocument.Parse(form.DataJson)) - Assert.Equal("Hide details", data.RootElement.GetProperty("detailsButtonTitle").GetString()); - form.SubmitForm("""{"action":"details"}"""); + Assert.DoesNotContain("Action.Submit", form.TemplateJson, StringComparison.Ordinal); + Assert.Contains("Chart guide", Assert.IsType(Assert.Single(information.GetContent())).Body, StringComparison.Ordinal); Assert.Null(page.Details); } [Theory] - [InlineData("")] - [InlineData("{")] - [InlineData("[]")] - [InlineData("""{"action":true}""")] - [InlineData("""{"action":"reset"}""")] - public void UnrecognizedFormInputCannotToggleDetails(string payload) + [InlineData(0, "100%", 534)] + [InlineData(43, "57%", 304.38)] + [InlineData(100, "0%", 0)] + public void RemainingBarDrainsWhileElapsedTimeFills(double used, string remaining, double width) { - using var service = _environment.CreateService(); - using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); - Assert.IsAssignableFrom(Assert.Single(page.GetContent())).SubmitForm(payload); - Assert.Null(page.Details); + using var data = Render(Snapshot() with { Primary = new(used, 300, Now.AddHours(4)) }); + var window = data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows")[0]; + Assert.Equal(remaining, window.GetProperty("remainingPercent").GetString()); + Assert.Equal("20%", window.GetProperty("elapsedPercent").GetString()); + var bar = XDocument.Parse(Uri.UnescapeDataString(window.GetProperty("remainingBarUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); + var fill = bar.Descendants(Svg + "rect").SingleOrDefault(rect => rect.Attribute("fill")?.Value == "#5C9EFA"); + if (width == 0) Assert.Null(fill); + else Assert.Equal(width, double.Parse(Assert.IsType(fill).Attribute("width")!.Value, CultureInfo.InvariantCulture), precision: 2); + } + + [Theory] + [InlineData(0, "15:00")] + [InlineData(3, "15:15")] + [InlineData(59, "16:00")] + public void ForecastStopsAtTheLimitWithoutExtendingToResetOrInventingEarlierMeasurements(int minutes, string expectedLabel) + { + var firstAt = Now.AddHours(-1); + var limitAt = Now.AddHours(3).AddMinutes(minutes); + var reset = Now.AddDays(4); + var chart = WeeklyUsageTrendChartRenderer.Create([new(firstAt, 70), new(Now, 57)], + new(43, 10080, reset), Now, TimeSpan.FromHours(2), new(limitAt, 0, true), + culture: CultureInfo.InvariantCulture, timeZone: TimeZoneInfo.Utc)!; + var svg = XDocument.Parse(Uri.UnescapeDataString(chart.ImageUrl["data:image/svg+xml;utf8,".Length..])); + var observed = Assert.Single(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is null); + Assert.Equal(2, observed.Attribute("points")!.Value.Split(' ').Length); + var forecast = Assert.Single(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null); + var points = forecast.Attribute("points")!.Value.Split(' '); + Assert.Equal(2, points.Length); + var endpoint = points[^1].Split(','); + var resetMarker = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "reset-end"); + Assert.True(double.Parse(endpoint[0], CultureInfo.InvariantCulture) < double.Parse(resetMarker.Attribute("x1")!.Value, CultureInfo.InvariantCulture)); + var zero = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "0"); + Assert.Equal(zero.Attribute("y1")!.Value, endpoint[1]); + Assert.Contains(svg.Descendants(Svg + "g"), label => label.Attribute("data-axis")?.Value == "estimated-limit" + && label.Attribute("data-axis-label")?.Value == expectedLabel); + Assert.Contains($"Estimated limit: {expectedLabel}", chart.AltText, StringComparison.Ordinal); + // Label rounding must not move the actual plotted limit point. + var marker = Assert.Single(svg.Descendants(Svg + "circle"), point => point.Attribute("data-marker")?.Value == "estimated-limit"); + Assert.Equal(endpoint[0], marker.Attribute("cx")!.Value); + } + + [Fact] + public void WeeklyRiskAppearsAtTheTopAndUnusedWindowsKeepConsistentHeadings() + { + var history = Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray(); + using var data = Render(Snapshot() with { Primary = null, Secondary = new(40, 10080, Now.AddDays(4)) }, history); + Assert.True(data.RootElement.GetProperty("hasWeeklyForecast").GetBoolean()); + Assert.Equal("Warning", data.RootElement.GetProperty("forecastColor").GetString()); + var codex = data.RootElement.GetProperty("quotaGroups")[0]; + Assert.Equal("Codex", codex.GetProperty("name").GetString()); + Assert.Equal("Codex · Weekly", codex.GetProperty("heading").GetString()); + Assert.True(codex.GetProperty("isPrimary").GetBoolean()); + Assert.Equal("Weekly", Assert.Single(codex.GetProperty("windows").EnumerateArray()).GetProperty("title").GetString()); + Assert.Contains("5-hour", codex.GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + Assert.True(UsageDashboardCard.TemplateJson.IndexOf("weeklyForecastSummary", StringComparison.Ordinal) + < UsageDashboardCard.TemplateJson.IndexOf("quotaGroups", StringComparison.Ordinal)); + } + + [Fact] + public void SingleAndPairedWindowsKeepTheSameBarThicknessAtTheirRespectiveWidths() + { + using var single = Render(Snapshot() with { Primary = null }); + using var paired = Render(Snapshot()); + var singleWindow = single.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows")[0]; + var pairedWindow = paired.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows")[1]; + Assert.False(singleWindow.GetProperty("showTitle").GetBoolean()); + Assert.True(pairedWindow.GetProperty("showTitle").GetBoolean()); + foreach (var key in new[] { "remainingBarUrl", "elapsedBarUrl" }) + { + static XElement SvgRoot(string url) => XDocument.Parse(Uri.UnescapeDataString(url["data:image/svg+xml;utf8,".Length..])).Root!; + var full = SvgRoot(singleWindow.GetProperty(key).GetString()!); + var half = SvgRoot(pairedWindow.GetProperty(key).GetString()!); + Assert.Equal((double)half.Attribute("width")! * 2, (double)full.Attribute("width")!); + Assert.Equal((double)half.Attribute("height")!, (double)full.Attribute("height")!); + } + } + + [Fact] + public void InformationOmitsMissingForecastSectionsAndRepeatedQuotaRows() + { + using var data = Render(Snapshot() with { Primary = null }); + var details = CodexUsageDockPage.FormatForecastDetails(data.RootElement); + Assert.DoesNotContain("### 5-hour", details, StringComparison.Ordinal); + Assert.Contains("### Weekly", details, StringComparison.Ordinal); + Assert.Contains("independent right axis", details, StringComparison.Ordinal); + Assert.Contains("conditional estimate", details, StringComparison.Ordinal); + Assert.DoesNotContain("Other quota windows", CodexUsageDockPage.FormatDetailsBody(Snapshot(), Now), StringComparison.Ordinal); + using var empty = Render(Snapshot() with { Primary = null, Secondary = null }); + Assert.Contains("No active quota windows reported", CodexUsageDockPage.FormatForecastDetails(empty.RootElement), StringComparison.Ordinal); + } + + [Fact] + public void InformationDatesUseTheSameEnglishLocalFormatAsTheDashboard() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("nl-NL"); + var expiry = Now.AddDays(19); + var details = CodexUsageDockPage.FormatResetCredits(new(1, [new("Full reset", "available", expiry)])); + Assert.Contains(expiry.ToLocalTime().ToString("ddd d MMM HH:mm", CultureInfo.InvariantCulture), details, StringComparison.Ordinal); + Assert.Equal(UsageTrendAnalyzer.FormatWeeklyLimitEstimate(expiry, Now, CultureInfo.InvariantCulture), + UsageTrendAnalyzer.FormatWeeklyLimitEstimate(expiry, Now)); + } + finally { CultureInfo.CurrentCulture = previous; } } [Theory] diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index b972153..71c5df0 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -220,26 +220,25 @@ public void DetailsPageContextMenuIncludesRefreshThenSettings() Assert.Collection( page.Commands, refresh => Assert.Equal("Refresh now", Assert.IsType(refresh).Title), - settings => Assert.Equal("Codex Usage settings", Assert.IsType(settings).Title)); + settings => Assert.Equal("Codex Usage settings", Assert.IsType(settings).Title), + information => Assert.Equal("Usage information", Assert.IsType(information).Title)); } [Fact] - public void DashboardKeepsDetailsCollapsedUntilRequested() + public void DashboardUsesFullWidthContentWithoutADetailsButton() { using var service = _environment.CreateService(); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); Assert.Null(page.Details); var main = Assert.IsAssignableFrom(Assert.Single(page.GetContent())); - main.SubmitForm("""{"action":"details"}"""); - var details = Assert.IsType
(page.Details); using var template = JsonDocument.Parse(main.TemplateJson); - Assert.Equal("Usage details", details.Title); - Assert.Equal(ContentSize.Medium, details.Size); Assert.Equal("AdaptiveCard", template.RootElement.GetProperty("type").GetString()); Assert.Contains("\"type\": \"Image\"", main.TemplateJson, StringComparison.Ordinal); - Assert.Contains("Used", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("Remaining", main.TemplateJson, StringComparison.Ordinal); + Assert.DoesNotContain("Action.Submit", main.TemplateJson, StringComparison.Ordinal); + Assert.DoesNotContain("singleWindow", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("Time elapsed", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("weeklyTrendAvailable", main.TemplateJson, StringComparison.Ordinal); Assert.DoesNotContain("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal); @@ -273,20 +272,20 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), var root = mainData.RootElement; Assert.False(root.GetProperty("hasNotice").GetBoolean()); - Assert.Equal("20%", DashboardWindow(root, "5-hour").GetProperty("usedPercent").GetString()); + Assert.Equal("80%", DashboardWindow(root, "5-hour").GetProperty("remainingPercent").GetString()); Assert.Equal("20%", DashboardWindow(root, "5-hour").GetProperty("elapsedPercent").GetString()); - Assert.Equal("2%", DashboardWindow(root, "Weekly").GetProperty("usedPercent").GetString()); + Assert.Equal("98%", DashboardWindow(root, "Weekly").GetProperty("remainingPercent").GetString()); Assert.Equal("14%", DashboardWindow(root, "Weekly").GetProperty("elapsedPercent").GetString()); - Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("usedBarUrl").GetString(), StringComparison.Ordinal); + Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("remainingBarUrl").GetString(), StringComparison.Ordinal); Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("elapsedBarUrl").GetString(), StringComparison.Ordinal); - Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("usedBarUrl").GetString(), StringComparison.Ordinal); + Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("remainingBarUrl").GetString(), StringComparison.Ordinal); Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("elapsedBarUrl").GetString(), StringComparison.Ordinal); Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal); Assert.Contains("Solid line connects continuous measurements", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); Assert.StartsWith("Forecast: recent 6 h only.", root.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); Assert.DoesNotContain("On track", main, StringComparison.Ordinal); - Assert.Equal("Default", DashboardWindow(root, "Weekly").GetProperty("usedColor").GetString()); + Assert.Equal("Default", DashboardWindow(root, "Weekly").GetProperty("remainingColor").GetString()); Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal); Assert.Contains("Projected at reset", root.GetProperty("weeklyProjection").GetString(), StringComparison.Ordinal); Assert.DoesNotContain("Resets and credits", main, StringComparison.Ordinal); @@ -334,7 +333,7 @@ public void WeeklyTrendUsesFullWindowHistoryBeforeTheLatestQuotaIncrease() refreshInterval: TimeSpan.FromMinutes(1), tokenUsage: TokenUsage( new DateOnly(2026, 7, 16), 250_000, - new DateOnly(2026, 7, 17), 500_000), showDetails: true); + new DateOnly(2026, 7, 17), 500_000)); using var document = JsonDocument.Parse(data); var root = document.RootElement; @@ -386,7 +385,7 @@ public void WeeklyDashboardUsesAdaptiveLocalHistory() } [Fact] - public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() + public async Task DashboardRefreshUpdatesMainContentAndInformationPage() { var now = DateTimeOffset.Now; var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -395,8 +394,7 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() () => throw new InvalidOperationException("Fallback should not run.")); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); var main = Assert.IsAssignableFrom(Assert.Single(page.GetContent())); - main.SubmitForm("""{"action":"details"}"""); - var details = Assert.IsType
(page.Details); + var information = Assert.IsType(Assert.IsType(page.Commands[2]).Command); var refresh = service.RefreshAsync(); @@ -421,16 +419,17 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() using (var refreshedData = JsonDocument.Parse(main.DataJson)) { Assert.False(refreshedData.RootElement.GetProperty("isLoading").GetBoolean()); - Assert.Equal("25%", DashboardWindow(refreshedData.RootElement, "5-hour").GetProperty("usedPercent").GetString()); + Assert.Equal("75%", DashboardWindow(refreshedData.RootElement, "5-hour").GetProperty("remainingPercent").GetString()); Assert.StartsWith( "data:image/svg+xml;utf8,", - DashboardWindow(refreshedData.RootElement, "5-hour").GetProperty("usedBarUrl").GetString(), + DashboardWindow(refreshedData.RootElement, "5-hour").GetProperty("remainingBarUrl").GetString(), StringComparison.Ordinal); Assert.Equal( "Projection will appear after another measurement.", refreshedData.RootElement.GetProperty("fiveHourProjection").GetString()); } + var details = Assert.IsType(Assert.Single(information.GetContent())); Assert.Contains("2 available", details.Body, StringComparison.Ordinal); Assert.Contains("Pro", details.Body, StringComparison.Ordinal); Assert.Contains("standalone Codex CLI app-server", details.Body, StringComparison.Ordinal); @@ -478,7 +477,7 @@ public async Task CompletedRefreshUpdatesExistingDockBandWithoutReloadingProvide [Fact] public void UsageProgressBarCreatesSvgDataUri() { - var bar = UsageDashboardCard.CreateProgressBarImageUrl(75, UsageBarPalette.Used); + var bar = UsageDashboardCard.CreateProgressBarImageUrl(75, UsageBarPalette.Remaining); Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal); Assert.Contains("%3Csvg", bar, StringComparison.OrdinalIgnoreCase); @@ -488,7 +487,7 @@ public void UsageProgressBarCreatesSvgDataUri() [Fact] public void UsageProgressBarHandlesNonFinitePercentage() { - var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN, UsageBarPalette.Used); + var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN, UsageBarPalette.Remaining); Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal); Assert.DoesNotContain("NaN", bar, StringComparison.Ordinal); @@ -562,7 +561,7 @@ [new DailyTokenUsage(new DateOnly(2026, 7, 11), 250_000)], new UsageHistoryEntry(now, 80), ], refreshInterval: TimeSpan.FromMinutes(1), - tokenUsage: tokenUsage, showDetails: true); + tokenUsage: tokenUsage); using var document = JsonDocument.Parse(data); var root = document.RootElement; @@ -839,7 +838,7 @@ public void WeeklyTrendChartRendersHostSafeLocalizedAxisLabels() Assert.Empty(root.Descendants(Svg + "text")); Assert.All(root.Descendants(Svg + "g").Where(group => group.Attribute("data-axis") is not null), group => Assert.NotEmpty(group.Descendants(Svg + "path"))); - Assert.Contains("left vertical scale is used quota", result.AltText, StringComparison.Ordinal); + Assert.Contains("left vertical scale is remaining quota", result.AltText, StringComparison.Ordinal); } [Fact] @@ -981,11 +980,11 @@ public void WeeklyTrendChartUsesTheZeroGridlineAsTheTokenBarBaseline() var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); var zeroGridline = Assert.Single( root.Descendants(Svg + "line"), - line => line.Attribute("data-grid")?.Value == "used-percent" + line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "0"); var zeroY = double.Parse(zeroGridline.Attribute("y1")!.Value, CultureInfo.InvariantCulture); var bottomGridY = root.Descendants(Svg + "line") - .Where(line => line.Attribute("data-grid")?.Value == "used-percent") + .Where(line => line.Attribute("data-grid")?.Value == "remaining-percent") .Max(line => double.Parse(line.Attribute("y1")!.Value, CultureInfo.InvariantCulture)); var dailyTokenBar = Assert.Single( root.Descendants(Svg + "rect"), @@ -1100,10 +1099,10 @@ public void UsagePaceWarnsWhenAllowanceRunsFarAheadOfElapsedTime() refreshInterval: TimeSpan.FromMinutes(1)); using var document = JsonDocument.Parse(data); - Assert.Equal("70%", DashboardWindow(document.RootElement, "5-hour").GetProperty("usedPercent").GetString()); + Assert.Equal("30%", DashboardWindow(document.RootElement, "5-hour").GetProperty("remainingPercent").GetString()); Assert.Equal("20%", DashboardWindow(document.RootElement, "5-hour").GetProperty("elapsedPercent").GetString()); Assert.DoesNotContain("Limit may be reached before reset", data, StringComparison.Ordinal); - Assert.Equal("Warning", DashboardWindow(document.RootElement, "5-hour").GetProperty("usedColor").GetString()); + Assert.Equal("Warning", DashboardWindow(document.RootElement, "5-hour").GetProperty("remainingColor").GetString()); } [Theory] @@ -1906,7 +1905,7 @@ public void WeeklyTrendEstimatesLimitWhenConsumptionWillExceedAllowanceBeforeRes [Fact] public void WeeklyTrendIncludesDateWhenTheEstimatedLimitIsNotToday() { - var now = DateTimeOffset.Now; + var now = new DateTimeOffset(2026, 9, 16, 0, 0, 0, TimeSpan.Zero); var estimated = now.AddHours(99); var trend = CodexUsageDockPage.FormatTrend( "Weekly usage trend", @@ -1916,7 +1915,7 @@ public void WeeklyTrendIncludesDateWhenTheEstimatedLimitIsNotToday() dataAvailable: true, maximumSampleAge: TimeSpan.FromMinutes(5)); - Assert.Contains($"limit may be reached around {estimated.ToLocalTime():ddd d MMM}.", trend, StringComparison.Ordinal); + Assert.Contains($"limit may be reached around {estimated.ToLocalTime().ToString("ddd d MMM", CultureInfo.InvariantCulture)}.", trend, StringComparison.Ordinal); } [Fact] diff --git a/CodexUsageDock.Tests/UsageFreshnessTests.cs b/CodexUsageDock.Tests/UsageFreshnessTests.cs index 04152d8..f8601fb 100644 --- a/CodexUsageDock.Tests/UsageFreshnessTests.cs +++ b/CodexUsageDock.Tests/UsageFreshnessTests.cs @@ -135,17 +135,18 @@ public void AdditionalBucketsUseSeparateTruthfulDurationRows() DefaultBucketId = "default", }; - var details = CodexUsageDockPage.FormatAdditionalBucketDetails(snapshot, Now); + var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var details = CodexUsageTablePage.Format(view, Now, TimeSpan.FromMinutes(1)); - Assert.Contains("Other quota windows", details, StringComparison.Ordinal); + Assert.DoesNotContain("Other quota windows", CodexUsageDockPage.FormatDetailsBody(snapshot, Now), StringComparison.Ordinal); Assert.Contains("Coding \\", details, StringComparison.Ordinal); Assert.Contains("15-minute", details, StringComparison.Ordinal); Assert.Contains("90-minute", details, StringComparison.Ordinal); - Assert.Contains("Default", details, StringComparison.Ordinal); + Assert.Contains("Codex", details, StringComparison.Ordinal); Assert.Contains("1-hour", details, StringComparison.Ordinal); - Assert.Contains("75% available", details, StringComparison.Ordinal); - Assert.Contains("50% available", details, StringComparison.Ordinal); - Assert.Equal(2, details.Split("75% available", StringSplitOptions.None).Length - 1); + Assert.Contains("75% remaining", details, StringComparison.Ordinal); + Assert.Contains("50% remaining", details, StringComparison.Ordinal); + Assert.Equal(2, details.Split("75% remaining", StringSplitOptions.None).Length - 1); } [Fact] diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index b1baec8..ce7de02 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -96,7 +96,10 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS }, new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, - new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quota tables and measured values without charts or color cues" }, + new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quotas and measured values without charts or color cues" }, +#if DEBUG + new CommandItem(new NavigationTestPage()) { Title = "Codex Usage navigation test", Subtitle = "Debug: reproduce missing footer actions after Back" }, +#endif ]; _settings.Changed += OnSettingsChanged; diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index 7de829a..556f8d3 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -13,18 +13,12 @@ internal sealed partial class CodexUsageDockPage : ContentPage, IDisposable private bool _disposed; private readonly CodexUsageService _usage; private readonly CodexUsageDockSettingsPage _settings; - private readonly UsageDashboardForm _mainContent; - private bool _showDetails; - private readonly Details _details = new() - { - Title = "Usage details", - Size = ContentSize.Medium, - }; + private readonly FormContent _mainContent = new() { TemplateJson = UsageDashboardCard.TemplateJson }; + private readonly UsageInformationPage _information = new(); public CodexUsageDockPage(CodexUsageService usage, CodexUsageDockSettingsPage settings) { _usage = usage; - _mainContent = new UsageDashboardForm(ToggleDetails) { TemplateJson = UsageDashboardCard.TemplateJson }; _settings = settings; _usage.Updated += OnUpdated; Id = "nl.mathijs.codexusage.details"; @@ -42,6 +36,7 @@ public CodexUsageDockPage(CodexUsageService usage, CodexUsageDockSettingsPage se Title = settings.Title, Icon = settings.Icon, }, + new CommandContextItem(_information) { Title = _information.Title }, ]; UpdatePresentation(); } @@ -61,8 +56,7 @@ internal static string FormatMainDataJson( TimeSpan refreshInterval, bool adaptiveWeeklyForecastEnabled = true, AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, - LocalTokenUsageSnapshot? tokenUsage = null, - bool showDetails = false) + LocalTokenUsageSnapshot? tokenUsage = null) { var freshness = GetFreshnessState(snapshot, now, refreshInterval); var dataAvailable = IsDataAvailable(snapshot, freshness); @@ -82,7 +76,6 @@ internal static string FormatMainDataJson( ? $"{resetCount} reset credit{(resetCount == 1 ? string.Empty : "s")}{(urgentReset ? $" · expires {FormatRelativeTime(nextExpiry!.Value, now)}" : string.Empty)}" : string.Empty, ["resetCreditsColor"] = urgentReset ? "Warning" : "Default", - ["detailsButtonTitle"] = showDetails ? "Hide details" : "Details", ["quotaGroups"] = CreateQuotaGroups(snapshot, now, dataAvailable), ["weeklyAvailable"] = UsageFreshness.IsValidWindow(snapshot.Secondary, now), }; @@ -95,10 +88,19 @@ internal static string FormatMainDataJson( data["weeklyBudget"] = snapshot.Secondary is { } weekly ? FormatWeeklyBudget(weekly, now, dataAvailable) : string.Empty; data["weeklyForecastSummary"] = FormatForecastSummary(weeklyTrend, now, dataAvailable, maximumSampleAge); + data["hasWeeklyForecast"] = UsageFreshness.IsValidWindow(snapshot.Secondary, now); + data["forecastColor"] = dataAvailable && weeklyTrend?.Forecast is { ReachesLimitBeforeReset: true } + ? "Warning" : "Default"; AddWeeklyTrendData( data, snapshot.Secondary, weeklyHistory, weeklyTrend, dataAvailable, snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession or UsageDataSource.LastConfirmed, - now, TrendMaximumGap(refreshInterval), showDetails ? tokenUsage : null); + now, TrendMaximumGap(refreshInterval), tokenUsage); + data["chartLegend"] = tokenUsage?.Status switch + { + LocalTokenUsageStatus.Complete => "Line: remaining % · bars: local tokens/day (right axis)", + LocalTokenUsageStatus.Partial => "Line: remaining % · bars: partial local tokens/day (right axis)", + _ => "Line: remaining % · local tokens unavailable", + }; return data.ToJsonString(); } @@ -117,48 +119,18 @@ private static string FormatDashboardNotice(CodexUsageSnapshot snapshot, DateTim : status; } - private static JsonArray CreateQuotaGroups(CodexUsageSnapshot snapshot, DateTimeOffset now, bool fresh) - { - var groups = new JsonArray(); - var defaultWindows = new List<(string Title, RateLimitWindow? Window)> - { - ("5-hour", snapshot.Primary), - ("Weekly", snapshot.Secondary), - }; - foreach (var bucket in snapshot.Buckets ?? []) - { - if (!string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal)) continue; - foreach (var window in new[] { bucket.Primary, bucket.Secondary }) - { - if (window is not null && window != snapshot.Primary && window != snapshot.Secondary) - defaultWindows.Add((FormatWindowDuration(window.WindowMinutes), window)); - } - } - groups.Add((JsonNode)CreateQuotaGroup("Codex", defaultWindows, now, fresh)); - foreach (var bucket in snapshot.Buckets ?? []) - { - if (string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal)) continue; - var name = UsageText.SanitizeExternal(bucket.Name) ?? UsageText.SanitizeExternal(bucket.Id) ?? "Additional quota"; - // Additional categories can use arbitrary durations in either slot. - var windows = new[] { bucket.Primary, bucket.Secondary } - .Where(window => window is not null) - .Select(window => ("Quota", window)) - .ToArray(); - groups.Add((JsonNode)CreateQuotaGroup(name, - windows.Length > 0 ? windows : [("Quota", null)], now, fresh)); - } - return groups; - } + private static JsonArray CreateQuotaGroups(CodexUsageSnapshot snapshot, DateTimeOffset now, bool fresh) => + new(UsageQuotaGroups.Create(snapshot).Select(group => (JsonNode)CreateQuotaGroup(group, now, fresh)).ToArray()); private static JsonObject CreateQuotaGroup( - string name, IEnumerable<(string Title, RateLimitWindow? Window)> windows, DateTimeOffset now, bool fresh) + UsageQuotaGroup group, DateTimeOffset now, bool fresh) { + var name = group.Name; + var columns = group.Windows.Count(item => UsageFreshness.IsValidWindow(item.Window, now)); var active = new JsonArray(); var inactive = new List(); - foreach (var (fallbackTitle, window) in windows) + foreach (var (title, window) in group.Windows) { - var title = window is { WindowMinutes: > 0 } ? FormatWindowDuration(window.WindowMinutes) : fallbackTitle; - if (string.Equals(title, "weekly", StringComparison.Ordinal)) title = "Weekly"; if (!UsageFreshness.IsValidWindow(window, now) || window is null) { inactive.Add($"{title} · {(window is null ? "Not reported" : "Awaiting refresh")}"); @@ -171,29 +143,27 @@ private static JsonObject CreateQuotaGroup( active.Add((JsonNode)new JsonObject { ["title"] = title, - ["usedPercent"] = $"{window.UsedPercent:0}%", - ["usedColor"] = !fresh ? "Default" : window.UsedPercent >= 90 ? "Attention" + ["showTitle"] = columns > 1, + ["remainingPercent"] = $"{window.RemainingPercent:0}%", + ["remainingColor"] = !fresh ? "Default" : window.UsedPercent >= 90 ? "Attention" : window.UsedPercent >= 70 ? "Warning" : "Default", - ["usedBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(window.UsedPercent, UsageBarPalette.Used), - ["usedBarAlt"] = $"{name}, {title}: {window.UsedPercent:0}% used{(fresh ? string.Empty : " at last observation")}.", + ["remainingSize"] = window.UsedPercent == 0 ? "default" : "large", + ["remainingBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(window.RemainingPercent, UsageBarPalette.Remaining, columns), + ["remainingBarAlt"] = $"{name}, {title}: {window.RemainingPercent:0}% remaining{(fresh ? string.Empty : " at last observation")}.", + ["separate"] = active.Count > 0, ["elapsedAvailable"] = elapsedAvailable, ["elapsedPercent"] = elapsedAvailable ? $"{elapsed:0}%" : "—", - ["elapsedBarUrl"] = elapsedAvailable ? UsageDashboardCard.CreateProgressBarImageUrl(elapsed, UsageBarPalette.Time) : string.Empty, + ["elapsedBarUrl"] = elapsedAvailable ? UsageDashboardCard.CreateProgressBarImageUrl(elapsed, UsageBarPalette.Time, columns) : string.Empty, ["elapsedBarAlt"] = elapsedAvailable ? $"{name}, {title}: {elapsed:0}% of time elapsed." : "Time comparison unavailable.", - ["reset"] = $"Resets {window.ResetsAt.ToLocalTime().ToString("ddd d MMM HH:mm", CultureInfo.InvariantCulture)}", + ["reset"] = $"Resets {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}", }); } - foreach (var window in active) - { - window!["showTitle"] = active.Count > 1; - } - var heading = active.Count == 1 ? $"{name} · {active[0]!["title"]!.GetValue()}" : name; return new JsonObject { ["name"] = UsageText.EscapeMarkdown(name), - ["heading"] = UsageText.EscapeMarkdown(heading), - ["singleWindow"] = active.Count == 1, + ["heading"] = UsageText.EscapeMarkdown(columns == 1 ? $"{name} · {active[0]!["title"]!.GetValue()}" : name), + ["isPrimary"] = group.IsPrimary, ["windows"] = active, ["inactiveWindows"] = string.Join(" · ", inactive), ["hasInactiveWindows"] = inactive.Count > 0, @@ -215,7 +185,7 @@ private static string FormatForecastSummary( if (trend?.Forecast is { } forecast) return forecast.ReachesLimitBeforeReset ? $"Estimated limit · {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, CultureInfo.InvariantCulture)}" - : $"Forecast · {100 - forecast.RemainingPercent:0}% used at reset"; + : $"Forecast · {forecast.RemainingPercent:0}% remaining at reset"; if (trend?.History is not { Length: > 0 } history) return "Forecast · collecting measurements"; if (now - history[^1].RecordedAt > maximumSampleAge) return "Forecast paused · fresh measurement needed"; var minutes = (history[^1].RecordedAt - history[0].RecordedAt).TotalMinutes; @@ -237,8 +207,6 @@ internal static string FormatDetailsBody( {FormatRestorationDetails(snapshot.Secondary, weeklyHistory ?? [], now)} - {FormatAdditionalBucketDetails(snapshot, now)} - ## Account and data - **Plan:** {FormatPlan(snapshot.PlanType)} @@ -247,75 +215,6 @@ internal static string FormatDetailsBody( {FormatError(snapshot)} """; - internal static string FormatAdditionalBucketDetails(CodexUsageSnapshot snapshot, DateTimeOffset now) - { - if (snapshot.Buckets is not { Count: > 0 } buckets) - { - return string.Empty; - } - - var rows = new List(); - foreach (var bucket in buckets) - { - var label = UsageText.SanitizeExternal(bucket.Name) - ?? UsageText.SanitizeExternal(bucket.Id) - ?? "Additional quota"; - var isDefaultBucket = string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal); - AppendAdditionalBucketWindow(rows, label, "primary", bucket.Primary, snapshot, now, isDefaultBucket); - AppendAdditionalBucketWindow(rows, label, "secondary", bucket.Secondary, snapshot, now, isDefaultBucket); - } - - return rows.Count == 0 - ? string.Empty - : $"## Other quota windows\n\n{string.Join(Environment.NewLine, rows)}"; - } - - private static void AppendAdditionalBucketWindow( - List rows, - string label, - string role, - RateLimitWindow? window, - CodexUsageSnapshot snapshot, - DateTimeOffset now, - bool skipKnownDefaultWindow) - { - if (window is null - || skipKnownDefaultWindow && (window == snapshot.Primary || window == snapshot.Secondary) - || !UsageFreshness.IsValidWindow(window, now)) - { - return; - } - - var safeLabel = UsageText.EscapeMarkdown(label); - var duration = FormatWindowDuration(window.WindowMinutes); - rows.Add($"- **{safeLabel} ({role}, {duration}):** {window.RemainingPercent:0}% available · resets {FormatRelativeTime(window.ResetsAt, now)}."); - } - - private static string FormatWindowDuration(int windowMinutes) - { - if (windowMinutes == (int)TimeSpan.FromHours(5).TotalMinutes) - { - return "5-hour"; - } - - if (windowMinutes == (int)TimeSpan.FromDays(7).TotalMinutes) - { - return "weekly"; - } - - if (windowMinutes > 0 && windowMinutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) - { - return $"{windowMinutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; - } - - if (windowMinutes > 0 && windowMinutes % 60 == 0) - { - return $"{windowMinutes / 60}-hour"; - } - - return $"{windowMinutes}-minute"; - } - internal static string FormatSummary( CodexUsageSnapshot snapshot, DateTimeOffset now, @@ -510,7 +409,7 @@ private static void AddWeeklyTrendData( { data["weeklyTrendAvailable"] = false; data["weeklyForecastStatus"] = trend?.ForecastStatus ?? "Forecast unavailable."; - data["weeklyTrendLegend"] = "Solid: used quota (%) · dashed: forecast · breaks: gaps or restored allowance. Token bars appear with Details; their scale is independent."; + data["weeklyTrendLegend"] = "Solid: remaining quota (%) · dashed: forecast · breaks: gaps or restored allowance. Bars: local tokens per day on an independent right-hand scale."; data["weeklyRestorationAvailable"] = false; if (window is not { } validWindow || !UsageFreshness.IsValidWindow(validWindow, now) @@ -550,9 +449,9 @@ private static void AddWeeklyTrendData( : "forecast pending sufficient fresh measurements"; data["weeklyTrendLegend"] = tokenUsage?.Status switch { - LocalTokenUsageStatus.Complete => $"Solid: used quota (%) · breaks: gaps or restorations · {forecastLegend} · bars: local tokens per day · amber: detected restorations", - LocalTokenUsageStatus.Partial => $"Solid: used quota (%) · breaks: gaps or restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", - _ => $"Solid: used quota (%) · breaks: gaps or restorations · {forecastLegend} · local token data unavailable · amber: detected restorations", + LocalTokenUsageStatus.Complete => $"Solid: remaining quota (%) · breaks: gaps or restorations · {forecastLegend} · bars: local tokens per day · amber: detected restorations", + LocalTokenUsageStatus.Partial => $"Solid: remaining quota (%) · breaks: gaps or restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", + _ => $"Solid: remaining quota (%) · breaks: gaps or restorations · {forecastLegend} · local token data unavailable · amber: detected restorations", }; var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); @@ -792,7 +691,31 @@ internal static string FormatError(CodexUsageSnapshot snapshot) } private static string FormatLocalTime(DateTimeOffset value, string format) => - value.ToLocalTime().ToString(format, CultureInfo.CurrentCulture); + value.ToLocalTime().ToString(format, CultureInfo.InvariantCulture); + + internal static string FormatForecastDetails(JsonElement data) + { + var body = new System.Text.StringBuilder("\n\n## Forecast and budget\n\n"); + if (data.GetProperty("fiveHourProjection").GetString() is { Length: > 0 } fiveHour) + body.Append("### 5-hour\n\n").Append(fiveHour).Append("\n\n"); + if (data.GetProperty("weeklyAvailable").GetBoolean()) + { + body.Append("### Weekly\n\n").Append(data.GetProperty("weeklyProjection").GetString()) + .Append("\n\n").Append(data.GetProperty("weeklyBudget").GetString()) + .Append("\n\n").Append(data.GetProperty("weeklyForecastStatus").GetString()).Append("\n\n"); + } + if (string.IsNullOrEmpty(data.GetProperty("fiveHourProjection").GetString()) + && !data.GetProperty("weeklyAvailable").GetBoolean()) + body.Append("No active quota windows reported.\n\n"); + return body.Append(""" + ## Chart guide + + - **Line:** remaining allowance (%). Gaps break the line; amber marks restored allowance. + - **Bars:** local tokens per day, using the independent right axis. Tokens measure activity, not quota consumption. + - **Dashed line:** a conditional estimate. Weekly forecasts need 30 minutes of fresh, continuous data; gaps or restored allowance restart the series. + - Remaining allowance falls; elapsed time rises. Equal bar lengths do not indicate a sustainable pace. + """).ToString(); + } private void UpdatePresentation() { @@ -816,29 +739,17 @@ private void UpdatePresentation() _usage.RefreshInterval, _settings.UseAdaptiveWeeklyForecast, presentation.AdaptiveWeeklyHistory, - presentation.TokenUsage, - _showDetails); + presentation.TokenUsage); using var mainData = JsonDocument.Parse(_mainContent.DataJson); var data = mainData.RootElement; - _details.Body = FormatDetailsBody(snapshot, now, presentation.WeeklyHistory, _usage.RefreshInterval) - + $"\n\n## Forecast and budget\n\n### 5-hour\n\n{data.GetProperty("fiveHourProjection").GetString()}\n\n### Weekly\n\n{data.GetProperty("weeklyProjection").GetString()}\n\n{data.GetProperty("weeklyBudget").GetString()}\n\n{data.GetProperty("weeklyForecastStatus").GetString()}\n\n## Chart guide\n\n{data.GetProperty("weeklyTrendLegend").GetString()}\n\nTime elapsed is a comparison with an even budget, not a forecast. Forecasts are conditional estimates. Weekly forecasts need 30 minutes of recent, continuous measurements; a gap or restored allowance starts a new series. Local tokens describe activity, not quota consumption." - + (_usage.HistoryStorageError is { } error ? $"\n\n> **Local storage:** {error}" : string.Empty); + _information.Update(FormatDetailsBody(snapshot, now, presentation.WeeklyHistory, _usage.RefreshInterval) + + FormatForecastDetails(data) + + (_usage.HistoryStorageError is { } error ? $"\n\n> **Local storage:** {error}" : string.Empty)); } RaiseItemsChanged(0); } - private void ToggleDetails() - { - lock (_presentationLock) - { - if (_disposed) return; - _showDetails = !_showDetails; - Details = _showDetails ? _details : null; - } - UpdatePresentation(); - } - private void OnUpdated(object? sender, EventArgs e) => UpdatePresentation(); internal void Refresh() => UpdatePresentation(); @@ -855,20 +766,27 @@ public void Dispose() } -internal sealed partial class UsageDashboardForm(Action toggleDetails) : FormContent +internal sealed partial class UsageInformationPage : ContentPage { - public override CommandResult SubmitForm(string payload) + private readonly object _contentLock = new(); + private MarkdownContent _content = new(string.Empty); + + internal UsageInformationPage() { - if (string.IsNullOrEmpty(payload) || payload.Length > 1024) return CommandResult.KeepOpen(); - try - { - using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 4 }); - if (document.RootElement.ValueKind == JsonValueKind.Object - && document.RootElement.TryGetProperty("action", out var action) - && action.ValueKind == JsonValueKind.String && action.GetString() == "details") - toggleDetails(); - } - catch (JsonException) { } - return CommandResult.KeepOpen(); + Id = "nl.mathijs.codexusage.information"; + Title = "Usage information"; + Name = "Open"; + Icon = new IconInfo("\uE946"); + } + + public override IContent[] GetContent() + { + lock (_contentLock) return [_content]; + } + + internal void Update(string body) + { + lock (_contentLock) _content = new MarkdownContent(body); + RaiseItemsChanged(0); } } diff --git a/CodexUsageDock/Pages/CodexUsageTablePage.cs b/CodexUsageDock/Pages/CodexUsageTablePage.cs index 1b39943..85deeee 100644 --- a/CodexUsageDock/Pages/CodexUsageTablePage.cs +++ b/CodexUsageDock/Pages/CodexUsageTablePage.cs @@ -27,50 +27,55 @@ internal CodexUsageTablePage(CodexUsageService service, Func? cl public override IContent[] GetContent() { lock (_gate) { return [_content]; } } - internal static string Format(UsagePresentation view, DateTimeOffset now, TimeSpan interval) + internal static string Format(UsagePresentation view, DateTimeOffset now, TimeSpan interval, TimeZoneInfo? timeZone = null) { + var displayTimeZone = timeZone ?? TimeZoneInfo.Local; var snapshot = view.Usage; var freshness = snapshot.Source is UsageDataSource.Initializing or UsageDataSource.Unavailable ? "Unavailable" : UsageFreshness.Classify(snapshot.UpdatedAt, now, interval, snapshot.Source == UsageDataSource.LastConfirmed).ToString(); - var body = new StringBuilder("# Codex usage in text\n\nA text alternative to the dashboard charts.\n\n") + var body = new StringBuilder("# Codex usage in text\n\n") .Append("Status: ").Append(freshness).Append(view.IsLoading ? "; refreshing" : string.Empty) .Append(". Source: ").Append(snapshot.SourceDisplayName).Append(".\n\n"); if (snapshot.Source is not (UsageDataSource.Initializing or UsageDataSource.Unavailable)) - body.Append("Observed UTC: ").Append(Utc(snapshot.UpdatedAt)).Append(". Percentages below describe that observation.\n\n"); + body.Append("Observed: ").Append(LocalTime(snapshot.UpdatedAt, displayTimeZone)) + .Append(". Percentages describe that observation.\n\n"); + body.Append("Times are local (").Append(UsageText.EscapeMarkdown(displayTimeZone.Id)).Append(").\n\n"); if (snapshot.OrdinaryUsageAllowed == false) body.Append("**Ordinary usage was reported blocked.**\n\n"); - body.Append("| Quota category / window | Remaining at observation | Reset UTC | Window state now |\n| --- | ---: | --- | --- |\n"); - AppendWindow(body, "Default five-hour", snapshot.Primary, now); - AppendWindow(body, "Default weekly", snapshot.Secondary, now); - foreach (var bucket in snapshot.Buckets?.Take(32) ?? []) + foreach (var group in UsageQuotaGroups.Create(snapshot)) { - var label = UsageText.SanitizeExternal(bucket.Name, 70) ?? UsageText.SanitizeExternal(bucket.Id, 70) ?? "Additional category"; - if (bucket.Id != snapshot.DefaultBucketId || bucket.Primary != snapshot.Primary) AppendWindow(body, label + " primary", bucket.Primary, now); - if (bucket.Id != snapshot.DefaultBucketId || bucket.Secondary != snapshot.Secondary) AppendWindow(body, label + " secondary", bucket.Secondary, now); + body.Append("## ").Append(UsageText.EscapeMarkdown(group.Name)).Append("\n\n"); + foreach (var item in group.Windows) AppendWindow(body, item.Title, item.Window, now, displayTimeZone); + body.Append('\n'); } body.Append("\nAvailable earned resets at observation: ").Append(snapshot.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Not reported") - .Append(".\n\n## Recent weekly observations\n\nUp to 20 measured points, without projected values.\n\n| Observed UTC | Remaining |\n| --- | ---: |\n"); + .Append(".\n\n## Recent weekly observations\n\nLast 20 measurements; remaining allowance.\n\n"); foreach (var point in view.WeeklyHistory.TakeLast(20)) - body.Append("| ").Append(Utc(point.RecordedAt)).Append(" | ").Append(point.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% |\n"); + body.Append("- ").Append(LocalTime(point.RecordedAt, displayTimeZone)).Append(": **") + .Append(point.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("%**\n"); body.Append("\n## Locally observed daily tokens\n\nThese are local activity totals, not account-wide billing or quota percentages.\n\n"); - body.Append("Availability: ").Append(view.TokenUsage.Status).Append(".\n\n| Local calendar date | Tokens |\n| --- | ---: |\n"); + body.Append("Availability: ").Append(view.TokenUsage.Status).Append(".\n\n"); + // Full-width text rows avoid the host Markdown table's narrow numeric cells. foreach (var day in view.TokenUsage.Days.TakeLast(8)) - body.Append("| ").Append(day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(" | ") - .Append(day.TotalTokens.ToString("N0", CultureInfo.InvariantCulture)).Append(" |\n"); + body.Append("- ").Append(day.Date.ToString("ddd d MMM yyyy", CultureInfo.InvariantCulture)).Append(": **") + .Append(day.TotalTokens.ToString("N0", CultureInfo.InvariantCulture)).Append("** tokens\n"); return body.ToString(); } - private static void AppendWindow(StringBuilder body, string label, RateLimitWindow? window, DateTimeOffset now) + private static void AppendWindow(StringBuilder body, string label, RateLimitWindow? window, DateTimeOffset now, TimeZoneInfo timeZone) { - body.Append("| ").Append(UsageText.EscapeMarkdown(label)); - if (window is null) { body.Append(" | Not reported | Not reported | Unknown |\n"); return; } - var valid = double.IsFinite(window.UsedPercent) && window.UsedPercent is >= 0 and <= 100 && window.WindowMinutes > 0; - body.Append(" (").Append(window.WindowMinutes.ToString(CultureInfo.InvariantCulture)).Append(" minutes) | ") - .Append(valid ? window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%" : "Invalid") - .Append(" | ").Append(Utc(window.ResetsAt)).Append(" | ") - .Append(!valid ? "Invalid" : window.ResetsAt <= now ? "Reset passed; refresh required" : "Active window").Append(" |\n"); + body.Append("- **").Append(UsageText.EscapeMarkdown(label)).Append(":** "); + if (window is null) { body.Append("Not reported\n"); return; } + var valid = double.IsFinite(window.UsedPercent) && window.UsedPercent is >= 0 and <= 100 + && window.WindowMinutes > 0 + && (long)window.WindowMinutes * TimeSpan.TicksPerMinute <= Math.Min(window.ResetsAt.Ticks, window.ResetsAt.UtcTicks); + body.Append(valid ? window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "% remaining" : "Invalid") + .Append(" · resets ").Append(LocalTime(window.ResetsAt, timeZone)); + if (window.ResetsAt <= now) body.Append(" · Reset passed; refresh required"); + body.Append('\n'); } - private static string Utc(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + private static string LocalTime(DateTimeOffset time, TimeZoneInfo timeZone) => + TimeZoneInfo.ConvertTime(time, timeZone).ToString("ddd d MMM yyyy HH:mm", CultureInfo.InvariantCulture); private void Refresh() { lock (_gate) diff --git a/CodexUsageDock/Pages/NavigationTestPage.cs b/CodexUsageDock/Pages/NavigationTestPage.cs new file mode 100644 index 0000000..824daa1 --- /dev/null +++ b/CodexUsageDock/Pages/NavigationTestPage.cs @@ -0,0 +1,92 @@ +#if DEBUG +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +// Exercises host navigation without usage updates, storage, or account dependencies. +internal sealed partial class NavigationTestPage : ContentPage +{ + internal const string CommandId = "nl.mathijs.codexusage.navigation-test"; + private readonly object _gate = new(); + private int _refreshCount; + private readonly FormContent _content = new() + { + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[ + {"type":"TextBlock","text":"Navigation test dashboard","weight":"Bolder","size":"Large","wrap":true}, + {"type":"TextBlock","text":"Expected footer: Refresh test dashboard, Settings, More (or localized equivalents).","wrap":true}, + {"type":"TextBlock","text":"Open Settings, or More > Usage information / Read usage in text. Use the host Back arrow. All dashboard actions must return immediately.","wrap":true}, + {"type":"TextBlock","text":"Capture missing buttons before pressing Refresh. Child actions must not remain on this dashboard.","wrap":true}, + {"type":"TextBlock","text":"Refresh count: ${refreshCount}","wrap":true}, + {"type":"TextBlock","text":"Debug fixture. Fixed content; no automatic refresh. Test actions do not access account data, save settings, or copy to the clipboard.","isSubtle":true,"wrap":true} + ]} + """, + DataJson = """{"refreshCount":0}""", + }; + + internal NavigationTestPage() + { + Id = CommandId; + Name = "Open"; + Title = "Codex Usage navigation test"; + var settings = new NavigationTestChildPage("settings", "Settings", new FormContent + { + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[ + {"type":"TextBlock","text":"Test settings. Use Back to return; the dashboard Settings action must still be visible.","wrap":true}, + {"type":"Input.Toggle","id":"testOnly","title":"Test toggle (not saved)","value":"false"} + ]} + """, + }, [HarmlessAction("Child settings action")]); + var information = new NavigationTestChildPage("information", "Usage information", + new MarkdownContent("# Test information\n\nThis page has no actions. Use Back: all dashboard actions must return."), []); + var text = new NavigationTestChildPage("text", "Read usage in text", + new MarkdownContent("# Test usage in text\n\nFixed example: 50% remaining. Use Back: the child Refresh and Copy actions must be replaced by the dashboard actions."), + [HarmlessAction("Child refresh"), HarmlessAction("Child copy (no clipboard)")]); + Commands = + [ + new CommandContextItem(new AnonymousCommand(RefreshDashboard) + { + Name = "Refresh test dashboard", Result = CommandResult.KeepOpen(), + }) { Title = "Refresh test dashboard" }, + new CommandContextItem(settings) { Title = settings.Title }, + new CommandContextItem(information) { Title = information.Title }, + new CommandContextItem(text) { Title = text.Title }, + ]; + } + + public override IContent[] GetContent() => [_content]; + + private void RefreshDashboard() + { + lock (_gate) + { + _refreshCount++; + _content.DataJson = new JsonObject { ["refreshCount"] = _refreshCount }.ToJsonString(); + } + // Match the real dashboard: update content, without republishing Commands. + RaiseItemsChanged(0); + } + + private static CommandContextItem HarmlessAction(string name) => new( + new AnonymousCommand(() => { }) { Name = name, Result = CommandResult.KeepOpen() }) { Title = name }; +} + +internal sealed partial class NavigationTestChildPage : ContentPage +{ + private readonly IContent _content; + + internal NavigationTestChildPage(string suffix, string title, IContent content, ICommandContextItem[] commands) + { + Id = $"{NavigationTestPage.CommandId}.{suffix}"; + Title = title; + Name = title; + _content = content; + Commands = commands; + } + + public override IContent[] GetContent() => [_content]; +} +#endif diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index e13e0b9..7f8b500 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -5,7 +5,7 @@ namespace CodexUsageDock; internal enum UsageBarPalette { - Used, + Remaining, Time, } @@ -15,138 +15,103 @@ internal static class UsageDashboardCard internal const int BarWidth = 536; - internal const string TemplateJson = """ + // Reuse the same windows before and after the primary chart. + private const string QuotaGroupTemplate = """ { - "type": "AdaptiveCard", - "body": [ + "type": "Container", "$data": "${quotaGroups}", "$when": "${isPrimary}", + "spacing": "small", + "items": [ + { "type": "TextBlock", "text": "${heading}", "weight": "bolder", "wrap": true }, { - "type": "ColumnSet", + "type": "ColumnSet", "spacing": "none", "columns": [ { - "type": "Column", "width": "stretch", - "items": [ - { - "type": "TextBlock", "text": "${notice}", - "wrap": true, "color": "Warning", "$when": "${hasNotice}" - }, - { - "type": "TextBlock", "text": "${resetCreditsSummary}", - "wrap": true, "color": "${resetCreditsColor}", - "$when": "${hasResetCredits}" - }, - { - "type": "TextBlock", "text": "Refreshing…", - "isSubtle": true, "$when": "${isLoading}" - } - ] - }, - { - "type": "Column", "width": "auto", + "type": "Column", "width": "stretch", "$data": "${windows}", + "spacing": "extraLarge", "separator": "${separate}", "items": [ + { "type": "TextBlock", "text": "${title}", "weight": "bolder", "wrap": true, "$when": "${showTitle}" }, { - "type": "ActionSet", - "actions": [ + "type": "ColumnSet", "spacing": "none", + "columns": [ { - "type": "Action.Submit", "title": "${detailsButtonTitle}", - "data": { "action": "details" } - } - ] - } - ] - } - ] - }, - { - "type": "Container", - "$data": "${quotaGroups}", - "spacing": "medium", "separator": true, - "items": [ - { "type": "TextBlock", "text": "${heading}", "weight": "bolder", "wrap": true }, - { - "type": "TextBlock", "text": "${inactiveWindows}", - "isSubtle": true, "spacing": "small", "wrap": true, - "$when": "${hasInactiveWindows}" - }, - { - "type": "ColumnSet", "spacing": "small", - "columns": [ - { - "type": "Column", "width": "stretch", "$data": "${windows}", - "items": [ - { - "type": "TextBlock", "text": "${title}", "weight": "bolder", "wrap": true, - "$when": "${showTitle}" + "type": "Column", "width": "stretch", + "items": [ { "type": "TextBlock", "text": "Remaining", "isSubtle": true } ] }, { - "type": "ColumnSet", "spacing": "small", - "columns": [ + "type": "Column", "width": "auto", + "items": [ { - "type": "Column", "width": "stretch", - "items": [ { "type": "TextBlock", "text": "Used", "isSubtle": true } ] - }, - { - "type": "Column", "width": "auto", - "items": [ - { - "type": "TextBlock", "text": "${usedPercent}", - "size": "large", "weight": "bolder", "color": "${usedColor}", - "horizontalAlignment": "right" - } - ] + "type": "TextBlock", "text": "${remainingPercent}", + "size": "${remainingSize}", "weight": "bolder", "color": "${remainingColor}", + "horizontalAlignment": "right" } ] - }, - { - "type": "Image", "url": "${usedBarUrl}", "altText": "${usedBarAlt}", - "size": "stretch", "height": "12px", "spacing": "none" - }, - { - "type": "ColumnSet", "spacing": "small", - "columns": [ - { - "type": "Column", "width": "stretch", - "items": [ { "type": "TextBlock", "text": "Time elapsed", "isSubtle": true } ] - }, - { - "type": "Column", "width": "auto", - "items": [ - { "type": "TextBlock", "text": "${elapsedPercent}", "horizontalAlignment": "right" } - ] - } - ] - }, + } + ] + }, + { + "type": "Image", "url": "${remainingBarUrl}", "altText": "${remainingBarAlt}", + "size": "stretch", "spacing": "none" + }, + { + "type": "ColumnSet", "spacing": "none", + "columns": [ { - "type": "Image", "url": "${elapsedBarUrl}", "altText": "${elapsedBarAlt}", - "size": "stretch", "height": "12px", "spacing": "none", "$when": "${elapsedAvailable}" + "type": "Column", "width": "stretch", + "items": [ { "type": "TextBlock", "text": "Time elapsed", "isSubtle": true } ] }, { - "type": "TextBlock", "text": "${reset}", - "isSubtle": true, "spacing": "small", "wrap": true + "type": "Column", "width": "auto", + "items": [ { "type": "TextBlock", "text": "${elapsedPercent}", "horizontalAlignment": "right" } ] } ] }, { - "type": "Column", "width": "stretch", "items": [], - "$when": "${singleWindow}" - } + "type": "Image", "url": "${elapsedBarUrl}", "altText": "${elapsedBarAlt}", + "size": "stretch", "spacing": "none", "$when": "${elapsedAvailable}" + }, + { "type": "TextBlock", "text": "${reset}", "isSubtle": true, "size": "small", "spacing": "none", "wrap": true } ] } ] }, { - "type": "Container", "separator": true, "spacing": "medium", - "$when": "${weeklyAvailable}", + "type": "TextBlock", "text": "${inactiveWindows}", "isSubtle": true, + "size": "small", "spacing": "small", "wrap": true, "$when": "${hasInactiveWindows}" + } + ] + } + """; + + internal static readonly string TemplateJson = $$""" + { + "type": "AdaptiveCard", + "body": [ + { "type": "TextBlock", "text": "${notice}", "wrap": true, "color": "Warning", "$when": "${hasNotice}" }, + { + "type": "TextBlock", "text": "${weeklyForecastSummary}", "wrap": true, + "weight": "bolder", "color": "${forecastColor}", "spacing": "small", "$when": "${hasWeeklyForecast}" + }, + { "type": "TextBlock", "text": "Refreshing…", "isSubtle": true, "$when": "${isLoading}" }, + {{QuotaGroupTemplate}}, + { + "type": "Container", "spacing": "small", "$when": "${weeklyTrendAvailable}", "items": [ - { "type": "TextBlock", "text": "Codex · weekly usage", "weight": "bolder" }, + { "type": "TextBlock", "text": "Codex · weekly remaining", "weight": "bolder" }, { "type": "Image", "url": "${weeklyTrendChartUrl}", "altText": "${weeklyTrendChartAlt}", - "size": "stretch", "spacing": "small", "$when": "${weeklyTrendAvailable}" + "size": "stretch", "spacing": "small" }, { - "type": "TextBlock", "text": "${weeklyForecastSummary}", - "isSubtle": true, "wrap": true, "spacing": "small" + "type": "TextBlock", "text": "${chartLegend} · dashed: estimate", + "isSubtle": true, "size": "small", "wrap": true, "spacing": "small" } ] + }, + {{QuotaGroupTemplate.Replace("${isPrimary}", "${!isPrimary}", StringComparison.Ordinal)}}, + { + "type": "TextBlock", "text": "${resetCreditsSummary}", "wrap": true, + "size": "small", "color": "${resetCreditsColor}", "$when": "${hasResetCredits}", "spacing": "medium" } ], "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", @@ -154,10 +119,13 @@ internal static class UsageDashboardCard } """; - internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette palette) + internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette palette, int columns = 2) { + // The host preserves SVG aspect ratio. Match intrinsic width to each column's share + // so a full-width window does not render a bar twice as thick as paired windows. + var width = BarWidth * 2d / Math.Max(1, columns); var normalized = double.IsFinite(percent) ? Math.Clamp(percent, 0, 100) : 0; - var progressWidth = (BarWidth - 2d) * normalized / 100; + var progressWidth = (width - 2d) * normalized / 100; var fillColor = palette switch { UsageBarPalette.Time => "#8A8A8A", @@ -167,14 +135,14 @@ internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette XNamespace svg = "http://www.w3.org/2000/svg"; var document = new XElement( svg + "svg", - new XAttribute("width", BarWidth), + new XAttribute("width", width), new XAttribute("height", BarHeight), - new XAttribute("viewBox", $"0 0 {BarWidth} {BarHeight}"), + new XAttribute("viewBox", FormattableString.Invariant($"0 0 {width} {BarHeight}")), new XElement( svg + "rect", new XAttribute("x", "0.5"), new XAttribute("y", "0.5"), - new XAttribute("width", BarWidth - 1), + new XAttribute("width", width - 1), new XAttribute("height", BarHeight - 1), new XAttribute("rx", "4.5"), new XAttribute("fill", "#7A7A7A"), diff --git a/CodexUsageDock/UsageQuotaGroups.cs b/CodexUsageDock/UsageQuotaGroups.cs new file mode 100644 index 0000000..8220576 --- /dev/null +++ b/CodexUsageDock/UsageQuotaGroups.cs @@ -0,0 +1,49 @@ +namespace CodexUsageDock; + +internal sealed record UsageQuotaWindow(string Title, RateLimitWindow? Window); +internal sealed record UsageQuotaGroup(string Name, bool IsPrimary, IReadOnlyList Windows); + +// Share display names and deduplication between the dashboard and its text alternative. +internal static class UsageQuotaGroups +{ + internal static IReadOnlyList Create(CodexUsageSnapshot snapshot) + { + var defaults = new List + { + CreateWindow(snapshot.Primary, "5-hour"), + CreateWindow(snapshot.Secondary, "Weekly"), + }; + var groups = new List { new("Codex", true, defaults) }; + foreach (var bucket in snapshot.Buckets?.Take(32) ?? []) + { + var windows = new[] { bucket.Primary, bucket.Secondary }.OfType(); + if (string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal)) + { + foreach (var window in windows) + { + // API primary/secondary slots need not match the classified five-hour/weekly slots. + if (!defaults.Any(item => item.Window == window)) defaults.Add(CreateWindow(window)); + } + continue; + } + + var name = UsageText.SanitizeExternal(bucket.Name, 70) + ?? UsageText.SanitizeExternal(bucket.Id, 70) ?? "Additional quota"; + var additional = windows.Select(window => CreateWindow(window)).ToArray(); + groups.Add(new(name, false, additional.Length > 0 ? additional : [CreateWindow(null)])); + } + return groups; + } + + private static UsageQuotaWindow CreateWindow(RateLimitWindow? window, string fallback = "Quota") => + new(window is { WindowMinutes: > 0 } ? FormatDuration(window.WindowMinutes) : fallback, window); + + private static string FormatDuration(int minutes) => minutes switch + { + 300 => "5-hour", + 10080 => "Weekly", + > 0 when minutes % 1440 == 0 => $"{minutes / 1440}-day", + > 0 when minutes % 60 == 0 => $"{minutes / 60}-hour", + _ => $"{minutes}-minute", + }; +} diff --git a/CodexUsageDock/UsageTrendAnalyzer.cs b/CodexUsageDock/UsageTrendAnalyzer.cs index f4a983b..48a953d 100644 --- a/CodexUsageDock/UsageTrendAnalyzer.cs +++ b/CodexUsageDock/UsageTrendAnalyzer.cs @@ -166,7 +166,7 @@ private static TrendAnalysis AnalyzeWeekly( internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateTimeOffset now, CultureInfo? culture = null, TimeZoneInfo? timeZone = null) { - var displayCulture = culture ?? CultureInfo.CurrentCulture; + var displayCulture = culture ?? CultureInfo.InvariantCulture; var displayTimeZone = timeZone ?? TimeZoneInfo.Local; var local = TimeZoneInfo.ConvertTime(estimated, displayTimeZone); if (estimated - now >= TimeSpan.FromDays(1)) @@ -184,7 +184,7 @@ internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateT private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) => estimated.ToLocalTime().Date == now.ToLocalTime().Date - ? estimated.ToLocalTime().ToString("HH:mm", CultureInfo.CurrentCulture) - : estimated.ToLocalTime().ToString("ddd d MMM HH:mm", CultureInfo.CurrentCulture); + ? estimated.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture) + : estimated.ToLocalTime().ToString("ddd d MMM HH:mm", CultureInfo.InvariantCulture); } diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index 674cb85..c924b82 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -18,7 +18,7 @@ internal static class WeeklyUsageTrendChartRenderer private const double TrendBottom = 207; private const double TrendHeight = TrendBottom - TrendTop; private const double DayLabelTop = 219; - private const string AxisLabelFill = "#888888"; + private const string AxisLabelFill = "#D0D0D0"; private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; internal static WeeklyUsageTrendChart? Create( IReadOnlyList history, @@ -73,7 +73,7 @@ internal static class WeeklyUsageTrendChartRenderer AddNowMarker(document, windowStart, window.ResetsAt, effectiveNow, calendarScale); AddRestorationMarkers(document, restorations, calendarScale); AddObservedLines(document, renderedSegments, calendarScale); - AddForecastLine(document, forecastSegment, usableForecast, window.ResetsAt, calendarScale); + AddForecastLine(document, forecastSegment, usableForecast, window.ResetsAt, now, calendarScale, displayCulture, displayTimeZone); var first = samples[0]; var last = samples[^1]; @@ -99,7 +99,10 @@ internal static class WeeklyUsageTrendChartRenderer new XAttribute("width", Width), new XAttribute("height", Height), new XAttribute("viewBox", $"0 0 {Width} {Height}"), - new XAttribute("role", "img")); + new XAttribute("role", "img"), + // A fixed plot background keeps the axes readable in both host themes. + new XElement(Svg + "rect", new XAttribute("width", Width), new XAttribute("height", Height), + new XAttribute("rx", "4"), new XAttribute("fill", "#202020"))); private static UsageHistoryEntry[] Normalize( IReadOnlyList history, @@ -317,7 +320,7 @@ private static void AddTrendGrid(XElement document) { foreach (var percent in new[] { 100, 50, 0 }) { - var y = GetTrendY(100 - percent); + var y = GetTrendY(percent); document.Add( new XElement( Svg + "line", @@ -328,7 +331,7 @@ private static void AddTrendGrid(XElement document) new XAttribute("stroke", "#7A7A7A"), new XAttribute("stroke-opacity", "0.42"), new XAttribute("stroke-width", "1"), - new XAttribute("data-grid", "used-percent"), + new XAttribute("data-grid", "remaining-percent"), new XAttribute("data-value", percent))); AddChartLabel( document, @@ -593,6 +596,10 @@ private static void AddObservedLines( new XAttribute("fill", "#5C9EFA"), new XAttribute("stroke", "#121212"), new XAttribute("stroke-width", "1"))); + var labelX = Math.Clamp(calendarScale.GetX(latest.RecordedAt) - 8, Left + 45, Width - Right - 8); + AddChartLabel(document, $"{latest.RemainingPercent:0}%", labelX, + Math.Clamp(GetTrendY(latest.RemainingPercent) - ChartLabelHeight - 8, TrendTop + 4, TrendBottom - ChartLabelHeight), + ChartLabelAlignment.End, "current"); } } @@ -601,7 +608,10 @@ private static void AddForecastLine( UsageHistoryEntry[]? latestSegment, UsageTrendForecast? forecast, DateTimeOffset windowEnd, - CalendarDayScale calendarScale) + DateTimeOffset now, + CalendarDayScale calendarScale, + CultureInfo culture, + TimeZoneInfo timeZone) { if (latestSegment is null || forecast is null) { @@ -629,11 +639,6 @@ private static void AddForecastLine( points.Add(new UsageHistoryEntry(end, forecast.RemainingPercent)); } - if (forecast.ReachesLimitBeforeReset && end < windowEnd) - { - points.Add(new UsageHistoryEntry(windowEnd, 0)); - } - document.Add( new XElement( Svg + "polyline", @@ -645,6 +650,17 @@ private static void AddForecastLine( new XAttribute("stroke-linejoin", "round"), new XAttribute("stroke-dasharray", "5 4"), new XAttribute("stroke-opacity", "0.9"))); + if (forecast.ReachesLimitBeforeReset) + { + var x = calendarScale.GetX(end); + document.Add(new XElement(Svg + "circle", new XAttribute("cx", Format(x)), + new XAttribute("cy", Format(GetTrendY(0))), new XAttribute("r", "3"), + new XAttribute("fill", "#5C9EFA"), new XAttribute("data-marker", "estimated-limit"))); + var label = UsageTrendAnalyzer.FormatWeeklyLimitEstimate(end, now, culture, timeZone); + var labelWidth = MeasureChartLabel(NormalizeChartLabel(label)); + AddChartLabel(document, label, Math.Clamp(x, Left + labelWidth / 2, Width - Right - labelWidth / 2), + TrendBottom - ChartLabelHeight - 8, ChartLabelAlignment.Center, "estimated-limit"); + } } private static void AddDailyTokenBars( @@ -716,13 +732,13 @@ private static string FormatAltText( var forecastText = forecast switch { { ReachesLimitBeforeReset: true } => $" Estimated limit: {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, culture, timeZone)}; actual usage may differ.", - { } => $" Forecast reaches about {100 - forecast.RemainingPercent:0}% used at reset; actual usage may differ.", + { } => $" Forecast reaches about {forecast.RemainingPercent:0}% remaining at reset; actual usage may differ.", null => " Forecast is unavailable.", }; var restorationText = restorations.Length > 0 ? $" {restorations.Length} allowance restoration{(restorations.Length == 1 ? " was" : "s were")} detected; the latest at {TimeZoneInfo.ConvertTime(restorations[^1].DetectedAt, timeZone).ToString("ddd d MMM HH:mm", culture)} increased remaining allowance from {restorations[^1].PreviousRemainingPercent:0}% to {restorations[^1].CurrentRemainingPercent:0}%. Amber markers show detected restorations." : " No allowance restorations were detected in this window."; - return $"Weekly quota trend from {period}. Used quota changed from {100 - first.RemainingPercent:0}% to {100 - last.RemainingPercent:0}% across {sampleCount} observations. The left vertical scale is used quota from 0% to 100%.{(tokenUsage is null ? string.Empty : " The independent right scale is locally observed total tokens per calendar day.")} Horizontal labels are local calendar dates, and reset markers bound the quota window. Solid line connects continuous measurements; gaps and allowance increases break the line; dashed line is a conditional forecast.{restorationText}{forecastText} {daily}"; + return $"Weekly quota trend from {period}. Remaining quota changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observations. The left vertical scale is remaining quota from 0% to 100%.{(tokenUsage is null ? string.Empty : " The independent right scale is locally observed total tokens per calendar day.")} Horizontal labels are local calendar dates, and reset markers bound the quota window. Solid line connects continuous measurements; gaps and allowance increases break the line; dashed line is a conditional forecast.{restorationText}{forecastText} {daily}"; } private static string FormatDailyTokenAltText( @@ -771,7 +787,7 @@ private static string FormatPoints( points.Select(point => $"{Format(calendarScale.GetX(point.RecordedAt))},{Format(GetTrendY(point.RemainingPercent))}")); private static double GetTrendY(double remainingPercent) => - TrendTop + Math.Clamp(remainingPercent, 0, 100) / 100 * TrendHeight; + TrendTop + (100 - Math.Clamp(remainingPercent, 0, 100)) / 100 * TrendHeight; private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 69038c2..80e3177 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -36,7 +36,7 @@ After every build and registration: 2. Run **Reload Command Palette Extension**. 3. Open **Settings > Extensions** and confirm that **Codex Usage** is enabled. -Do not register a development manifest over a Microsoft Store installation. Use an isolated Windows user or test VM for development package registration. +The low-level registration command refuses to replace a Microsoft Store installation. Use an isolated Windows user or test VM, or explicitly choose the current-account workflow below. Development RID builds are self-contained. Do not override `SelfContained=false`: MSIX tooling places app-local .NET host files in the output, and combining those files with a framework-dependent runtime configuration prevents the host from finding either an app-local or machine-wide framework. @@ -82,10 +82,52 @@ By default, the script only reads the source and generated manifests, verifies t .\scripts\test-integration.ps1 -Architecture ARM64 -Register ``` -`-Register` never removes or replaces a non-development Store installation. It refreshes or switches an existing development package under this repository's `CodexUsageDock/bin` tree in place with `ForceUpdateFromAnyVersion`, so a failed update leaves the prior registration intact. A development registration from any other location is refused. Windows may close a running Codex Usage Dock process during this refresh. On Windows builds older than 26100, the script cannot use the synchronous `AppExtensionCatalog.FindAll()` API and reports that discovery still needs manual verification. It never automates Command Palette UI input. After it completes, run **Reload Command Palette Extension** manually. +`-Register` never removes or replaces a non-development Store installation. It refreshes or switches an existing development package under this repository's `CodexUsageDock/bin` tree in place with `ForceUpdateFromAnyVersion`, so a failed update leaves the prior registration intact. A development registration from any other location is refused. Windows may close a running Codex Usage Dock process during this refresh. On Windows builds older than 26100, the script cannot use the synchronous `AppExtensionCatalog.FindAll()` API and reports that discovery still needs manual verification. It never automates Command Palette UI input. After it completes, run **Reload Command Palette Extension** manually. `-ArtifactsOnly` checks build outputs without registration or activation checks and cannot be combined with `-Register`. + +### Test in your current Windows account + +For explicitly requested local testing with your existing settings, usage history, and Codex sign-in, run from the repository root: + +```powershell +.\scripts\test-local.ps1 +``` + +The workflow first checks Smart App Control. When enforcement is enabled, it stops before building, stopping the extension, or changing registration because these local builds are unsigned. The low-level `test-integration.ps1 -Register` command applies the same check. Read-only integration preflight reports this incompatibility as a failure; `-ArtifactsOnly` still validates build outputs independently of host policy. Store recovery remains available. + +This builds Debug for the host architecture, validates the artifacts, stops only this account's registered extension process, backs up application settings and history under `%LOCALAPPDATA%\CodexUsageDock-development-backups`, and switches the current-user registration to the local build. A Store installation is removed only after the build, artifact checks, and backup succeed. Repeating the command rebuilds and refreshes the development registration in place. `-SkipBuild` reuses existing artifacts only if they pass freshness checks; `-Architecture x64` or `-Architecture ARM64` overrides the default. + +Afterward, run **Reload Command Palette Extension** and open **Codex Usage**. The local build uses the same data and extension identity. It remains registered to this checkout, so keep the build directory available. A development package from another checkout is refused. This workflow does not change Windows Developer Mode or signing policy; Windows may require development registration to be permitted on the machine. + +Return to the version currently available through Microsoft Store with: + +```powershell +.\scripts\test-local.ps1 -RestoreStore +``` + +The return command backs up the current data, unregisters the development build with data preservation, and installs the official Store product using App Installer (`winget`). It verifies the Store signature and package health. It needs Store/network access and does not promise the exact version installed before testing. A failed switch attempts recovery when no package remains registered; the backup is retained even if recovery fails. If a package is already registered after a partial failure, it is left in place for inspection. + +Settings and history remain shared with the Store version; these backups do not include Codex credentials or session logs. Test migrations against isolated data first. [Windows only supports `-PreserveApplicationData` for development registrations](https://learn.microsoft.com/en-us/powershell/module/appx/remove-appxpackage#-preserveapplicationdata), so the Store-to-development switch explicitly backs up and restores the application's data directories. + +Script regression tests use mocked package operations and isolated Pester storage: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -Command "Import-Module Pester -RequiredVersion 3.4.0; Invoke-Pester .\scripts\tests\test-local.Tests.ps1 -EnableExit" +``` + +### Smart App Control + +A healthy development registration does not prove that Windows will load the extension. Smart App Control can block the unsigned `CodexUsageDock.dll` after the COM-server executable starts. Developer Mode permits development registration but does not supply a trusted code signature. Check **Event Viewer > Applications and Services Logs > Microsoft > Windows > CodeIntegrity > Operational** for enforcement events (3077) naming the extension. + +The current local workflow intentionally refuses development registration when `VerifiedAndReputablePolicyState` is `1` (enforcement). This is a compatibility check for the unsigned workflow, not a complete signature or reputation validator. Evaluation mode may allow a build now and later switch to enforcement; managed application-control policies can also block execution. Successful registration and artifact checks still require a functional UI test. + +For an immediately usable installation, run `scripts/test-local.ps1 -RestoreStore` to restore the Microsoft-signed Store version with a data backup. Local source changes remain in the checkout but are not present in the Store build. Test unreleased code in a compatible isolated development environment or establish signing through a trusted provider. Self-signing or reinstalling the same unsigned files does not establish Smart App Control trust. These scripts do not change security policy, certificates, or Defender exclusions. + +References: [Smart App Control signing requirements](https://learn.microsoft.com/en-us/windows/apps/develop/smart-app-control/code-signing-for-smart-app-control), [policy states and event logs](https://learn.microsoft.com/en-us/windows/apps/develop/smart-app-control/test-your-app-with-smart-app-control). ### Pre-Store x64 and ARM64 matrix +For missing footer actions after navigating Back, use the [navigation reproduction test](docs/navigation-reproduction.md). Debug builds expose **Codex Usage navigation test** with fixed content and harmless actions. The automated `NavigationContractTests` check extension state; the documented host UI steps are required to verify navigation and rendering. + Complete every row on a clean x64 environment and a separate clean ARM64 environment. Record the Windows and PowerToys versions, package version, date, result, and supporting screenshot or diagnostic output. Do not test both architectures by repeatedly replacing packages in a production user profile. | Scenario | x64 | ARM64 | Acceptance criteria | @@ -96,7 +138,7 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ | Automated preflight | Required | Required | `test-integration.ps1` passes manifest, package, CLSID, COM, and AppExtension checks. | | Start-menu visibility | Required | Required | Codex Usage does not appear as a standalone app in Start; it is activated only by Command Palette through its packaged COM registration. | | Discovery and reload | Required | Required | After **Reload Command Palette Extension**, **Codex Usage** appears once under **Settings > Extensions** and can be enabled. | -| Dashboard and Details | Required | Required | Opening **Codex Usage** starts with Details collapsed. Codex, Spark, and other reported categories share Used/time bars with actual window durations, one local reset date, and large used percentages; missing/expired windows take a compact text row and never become zero. Verify that high usage remains visible, stale/last-confirmed observations keep their age and suppress time comparisons, and local fallback remains labeled. **Details** opens and closes the native pane and survives refresh without losing focus or scroll unnecessarily. It contains reset-credit expirations, credit balance, account/source status, forecast basis, remaining daily/hourly budget, chart explanation, and restoration history. The main chart uses a 0–100% used-quota axis, smooth readable labels, solid observations, conditional dashed forecasts, reset/current-time markers, and amber restorations. Increased usage moves upward; restored allowance moves downward and breaks the line. Gaps also break the line; isolated samples remain visible. The compact forecast status explains the recent continuous-data requirement and becomes an estimate only after its gates pass. Local token bars and their independent right-hand axis appear only while Details is open and must identify partial coverage; they use local calendar days, including partial boundary days. Verify narrow/wide layouts, light/dark/high-contrast themes, label contrast, keyboard access to Details, and refresh without clipping or freezing. | +| Dashboard and information | Required | Required | Opening **Codex Usage** shows remaining allowance, with no Details button. Verify that 0% used gives a full 100% remaining bar and exhausted quota gives an empty bar, while elapsed time fills upward. Codex and Spark share model/window headings; a single active window uses full width, and missing/expired states remain explicit. The weekly forecast status precedes quotas and the graph precedes additional categories. The graph falls with consumption, rises and breaks at restorations, preserves gaps and isolated points, labels the latest observation, and never invents an earlier 100% reading. Its dashed forecast stops at the estimated zero point with a date label. Local daily token bars and the right axis appear by default, with partial/unavailable coverage identified. **More > Usage information** must open through native command navigation and show credits, source, forecast basis, budgets, chart guide and history; verify back navigation and live refresh on that page. Check narrow/wide layouts, light/dark/high-contrast themes, the fixed dark chart background, labels, keyboard focus, and refresh without clipping or freezing. Stale observations retain age and suppress forecasts and time comparisons. | | Dock band | Required | Required | The band can be added, each enabled item opens details, and values update while Command Palette remains responsive. | | Settings | Required | Required | Visibility, reset-time, refresh-interval, and adaptive-forecast choices apply immediately and persist after restarting Command Palette. Verify that disabling pauses learning without replaying measurements collected while paused, retains history, and that deleting learned history requires confirmation. | | Live app-server | Required | Required | With a signed-in standalone Codex CLI, the details page identifies the CLI app-server as the source and refreshes live data. | diff --git a/README.md b/README.md index 7748f3a..f4c34e5 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ GitHub releases identify source versions and may differ from the version availab | `2 resets · 10.00` | Two earned resets are available; `10.00` is the reported credits balance. | | `Reset - …` / `Expires - …` | The next quota reset or earned-reset expiry, using your local time zone and regional date format. | -Dock-band percentages show **remaining allowance**. Open **Codex Usage** for a compact dashboard: Codex and additional categories such as Spark use the same **Used** and **Time elapsed** bars. Dashboard percentages and the weekly line show **usage consumed**, on a 0–100% scale. Missing windows are marked **Not reported** or **Awaiting refresh**, never shown as zero. +Dock-band and dashboard percentages show **remaining allowance**. Codex and additional categories such as Spark use the same **Remaining** and **Time elapsed** bars: allowance drains from 100% toward 0%, while elapsed time increases toward 100%. Missing windows are marked **Not reported** or **Awaiting refresh**, never shown as zero. -Values refresh every minute by default. **Details** opens the data source, measurement time, reset-credit expirations, credits balance, and explanatory text; it starts collapsed. During an outage, **Last confirmed** retains the previous observation with its age and pauses time comparisons and forecasts. Local fallback data remains labeled. +Values refresh every minute by default. Open **Usage information** from the dashboard's **More** menu for the data source, measurement time, reset-credit expirations, credits balance, and explanatory text. During an outage, **Last confirmed** retains the previous observation with its age and pauses time comparisons and forecasts. Local fallback data remains labeled. -The weekly graph shows one short forecast status. Forecasts require at least 30 minutes of fresh, continuous measurements; their basis and the remaining daily budget are under **Details**. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. Daily token bars appear only while **Details** is open; they reflect local session activity and are **not an exact measure of quota consumption**. +The weekly forecast status appears above the quotas, and the graph appears before additional categories. The line shows remaining allowance; a forecast that reaches zero ends at its estimated limit time. Forecasts require at least 30 minutes of fresh, continuous measurements; their basis and the daily budget are under **Usage information**. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. Daily token bars and their independent right-hand axis appear by default when available; they reflect local session activity and are **not an exact measure of quota consumption**. See the [user guide](USER_GUIDE.md#charts-and-forecasts) for chart legends, adaptive forecasts, and data handling. @@ -94,7 +94,7 @@ These pages are available in Command Palette: | Page | Purpose | | --- | --- | -| **Codex usage in text** | Read quotas, reset times, trends, and token totals without relying on charts or color. Also available from Details. | +| **Codex usage in text** | Read quotas, reset times, trends, and token totals without relying on charts or color. | | **Codex account activity** | View account-wide token summaries and up to 30 recent server-calendar days on compatible CLI versions. | | **Codex usage history** | Browse retained quota observations and export CSV or JSON files. | | **Codex task usage and earned resets** | Request server usage estimates for a task ID or use an existing earned reset on compatible CLI versions. | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a5e2d40..14c6b22 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -6,11 +6,11 @@ For installation and first use, see the [README](README.md#quick-start). This gu ## Charts and forecasts -The dashboard groups Codex and additional quota categories such as Spark into the same compact windows. Each reported window shows **Used**, **Time elapsed**, and a local reset date. The two bars share a 0–100% scale: blue indicates consumption and gray indicates elapsed time. Large percentages also mean used quota. Missing or expired windows take one short line, without fabricated zero values. An unavailable time comparison is shown as **—**. Fresh high usage is highlighted by the percentage; the comparison does not predict whether future usage will fit. +The dashboard groups Codex and additional quota categories such as Spark into the same compact windows. Each reported window shows **Remaining**, **Time elapsed**, and a local reset date. Blue shows remaining allowance, falling from 100% toward 0%; gray shows elapsed time, rising toward 100%. Large percentages also mean remaining allowance. Because these bars move in opposite directions, equal lengths do not indicate a sustainable pace. Missing or expired windows take one short line, without fabricated zero values. An unavailable time comparison is shown as **—**. Fresh low allowance is highlighted by the percentage. -The weekly chart also shows **used quota** on a 0–100% scale. Its solid line rises as quota is consumed and breaks at measurement gaps and allowance restorations; a restoration reduces the used percentage. Isolated measurements remain visible as points. Amber markers identify detected restorations. The chart has smooth font outlines and a short forecast status; the detailed explanation and restoration history are under **Details**. Dashboard date labels use English names and local time to match the English interface; Dock-band subtitles retain regional formatting. +The weekly chart shows **remaining allowance**, with 100% at the top and 0% at the bottom. Its solid line falls as quota is consumed and breaks at measurement gaps and allowance restorations; a restoration increases remaining allowance. Isolated measurements remain visible as points, and the latest observation is labeled. No earlier 100% observation is invented. A forecast reaching zero ends at its estimated limit time. Its label uses the same rounded estimate as the dashboard and information page; the plotted point retains the calculated time. Amber markers identify detected restorations. The chart uses a fixed dark background and brighter font outlines for readable axes in either host theme. Dashboard, information, and text-view dates use English names and local time; Dock-band subtitles retain regional formatting. -Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. **Details** starts collapsed to leave the dashboard the full width. Opening it reveals forecast explanations, the remaining daily/hourly budget, reset-credit expirations, credits, and source information. It also enables locally observed total-token bars on an independent daily scale. Hiding Details removes those bars again; refreshing retains the current Details choice for that page. +Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. Local daily token bars and their independent right-hand scale appear by default when available; a short legend distinguishes the two axes and identifies partial data. The weekly forecast status appears at the top, followed by Codex quotas and the weekly chart, then additional quota categories and reset credits. A single reported window uses the full width with a combined model/window heading and bars scaled to match paired windows; unused windows retain both bars with smaller percentages. **Usage information**, in the dashboard's **More** menu, opens a regular page with forecast explanations, the daily/hourly budget, reset-credit expirations, credits, source information, and restoration history. It omits repeated quota rows and absent forecast sections and includes a short chart guide. There is no Details toggle or dependency between that information page and the token chart. Weekly projections require at least 30 minutes of fresh, continuous measurements. Recent pace uses at most the last six hours of that segment, so an old idle period cannot dilute the estimate indefinitely. With insufficient learned history, the projection explicitly describes what would happen if that recent pace continued. Its status identifies the measurement duration. The five-hour projection retains its shorter observation requirement. @@ -20,13 +20,13 @@ Six-hour patterns require at least three usable weeks with at least three observ A segment starts again after an allowance increase or a gap longer than three times the freshness allowance (the greater of five minutes and the refresh interval). Text, chart, and alert projections pause until enough fresh measurements are available. Without usable history, a meaningful decrease is also required; with usable history, observed idle time can still produce an estimate. Weekly warning notifications follow the same adaptive setting and calculation as the dashboard. -Projections are conditional estimates, not calibrated probabilities. Estimated exhaustion at least a day away shows a date; nearer estimates are rounded up to a quarter hour. The chart retains the calculated points. The budget under Details shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. Used/time bars compare consumption with elapsed window time; neither they nor the budget guarantee future consumption. The dashboard therefore omits reassuring pace labels such as “On track”. The model is tested with deterministic scenarios and chronological synthetic benchmarks; its accuracy on your actual future weeks has not been established. +Projections are conditional estimates, not calibrated probabilities. Estimated exhaustion at least a day away shows a date; nearer estimates are rounded up to a quarter hour. The chart retains the calculated points. The budget under Usage information shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. Remaining/time bars show allowance and elapsed window time; neither they nor the budget guarantee future consumption. The dashboard therefore omits reassuring pace labels such as “On track”. The model is tested with deterministic scenarios and chronological synthetic benchmarks; its accuracy on your actual future weeks has not been established. -The extension also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. Limits update first; token bars update independently when local analysis finishes. These token bars are local activity observations, not an exact accounting of allowance consumption. The details page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. +The extension also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. Limits update first; token bars update independently when local analysis finishes. These token bars are local activity observations, not an exact accounting of allowance consumption. The Usage information page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. ### Text alternative -**Codex usage in text**, also available from Details, provides quota tables, reset times, recent measured weekly points, and local daily token totals without relying on charts or color. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. +**Codex usage in text**, available from the extension command list, groups quotas with the same model and window names as the dashboard. A default quota appears once even when its API position differs from its duration. Short text rows use the available width for reset times, recent measured weekly points, and full local daily token totals, avoiding narrow table cells. Times are local, with the time zone identified; history exports continue to use UTC. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. ## Dock settings @@ -44,7 +44,7 @@ You can enable or pause the adaptive weekly forecast. Pausing keeps its learned ### Saving settings -The extension saves these choices in `CodexUsageDock/settings.json` under the current user's Windows local application data directory and restores them before refreshing after a restart. If saving fails, the page explains that the choices apply only to the running session and lets you save again. Deleting learned history confirms success only after the cleared state is saved. History read/write failures also appear in Details. +The extension saves these choices in `CodexUsageDock/settings.json` under the current user's Windows local application data directory and restores them before refreshing after a restart. If saving fails, the page explains that the choices apply only to the running session and lets you save again. Deleting learned history confirms success only after the cleared state is saved. History read/write failures also appear in Usage information. ## Account activity @@ -72,7 +72,7 @@ Codex is detected using the environment configuration described in [Requirements ### Quota categories -The dashboard renders separate quota categories returned by newer Codex versions with the same Used/time bars, including durations other than five hours or one week. The familiar Dock entries and weekly forecast describe only the default category. Percentages from different categories are never added or averaged. Available reset credits have a compact count; an expiry within 24 hours is highlighted. The full credits balance is under Details, and credits remain usable even when there are no percentage windows. +The dashboard renders separate quota categories returned by newer Codex versions with the same Remaining/time bars, including durations other than five hours or one week. The familiar Dock entries and weekly forecast describe only the default category. Percentages from different categories are never added or averaged. Available reset credits have a compact count; an expiry within 24 hours is highlighted. The full credits balance is under Usage information, and credits remain usable even when there are no percentage windows. ### Freshness and account isolation diff --git a/docs/navigation-reproduction.md b/docs/navigation-reproduction.md new file mode 100644 index 0000000..646086e --- /dev/null +++ b/docs/navigation-reproduction.md @@ -0,0 +1,64 @@ +# Reproduce missing footer actions after Back + +## Purpose and limits + +The reported behavior is that returning from **Usage information** or **Read usage in text** hides the dashboard footer actions; returning from **Settings** can hide only Settings. This test separates extension command state from the PowerToys host's navigation and rendering. + +Debug builds expose **Codex Usage navigation test**. Its dashboard and settings use `FormContent`, and its information and text pages use `MarkdownContent`, matching the production page types. The dashboard has four stable commands. The children have zero, two, and one commands respectively. Child actions have distinctive names to reveal a stale footer. The fixture has no service, timer, file access, settings persistence, or clipboard operations. The normal extension provider still starts its usual usage service when loaded. + +The fixture deliberately does not republish `Commands` on Refresh. A refresh changes only an in-memory counter and raises the content notification, as the real dashboard does. This is a diagnostic fixture, not a navigation fix. + +## Setup + +1. Build and install the Debug package using the authorized [local test workflow](../DEVELOPMENT.md#test-in-your-current-windows-account). +2. Run **Reload Command Palette Extension**. Search for **Codex Usage navigation test** in Command Palette and open it. +3. Record PowerToys and Command Palette versions, architecture, build configuration, source revision (including uncommitted changes), display scaling, language, and window size. Keep the size constant during the test. +4. Capture the baseline dashboard, including its title and footer. At normal width, expect **Refresh test dashboard**, **Settings**, and **More** (localized by the host). More must contain **Usage information** and **Read usage in text**. Record all menu entries; the host can also list the visible commands there. + +No Codex account data is needed by the fixture. Do not invoke the real settings page's history-deletion action during the production comparison below. + +## Host UI test + +Start each route from a freshly opened test dashboard with the expected baseline footer. Use the host's visible Back arrow, not a command that opens another dashboard instance. + +| Route | Child page check | Expected immediately after Back | +| --- | --- | --- | +| More > Usage information | Information text; no child footer commands | Dashboard title and all baseline actions return | +| More > Read usage in text | Footer has **Child refresh** and **Child copy (no clipboard)** | Dashboard actions return; neither child action remains | +| Settings | Test toggle and **Child settings action** | Dashboard actions return, including Settings; child action disappears | + +For each route: + +1. Open the child and capture its title/footer. +2. Click Back, wait one second without other input, then capture the entire dashboard and footer **before** refreshing or reopening anything. +3. If actions are missing, press **Ctrl+K** and capture whether the menu still exposes them. Close the menu. Record whether opening the menu restored the footer. +4. If accessible, invoke **Refresh test dashboard**. The counter must increment. Record separately whether refresh restores the footer; this is recovery evidence, not a passing Back result. +5. Reopen the fixture and repeat twice more. Then run all three routes in sequence without reopening between routes, if the actions remain accessible. +6. Repeat using Escape as the host's back gesture. Record if Escape closes the panel rather than navigating Back; do not treat closing/reopening as successful Back navigation. + +Run the same routes on the normal **Codex Usage** dashboard opened from the Dock. Also compare opening that dashboard from Command Palette search. Record the entry point for each result. This comparison includes real background updates and distinguishes a generic ContentPage issue from a Dock-only or service-update issue. The fixture itself does not simulate automatic updates. + +## Record results + +Copy this table into a local test note; screenshots can go under ignored `artifacts/audit/`. Avoid account identifiers or usage details in any externally shared evidence; prefer the synthetic fixture. + +| Page / entry point | Route | Back gesture | Cycle | Expected actions after Back | Observed actions / menu | Screenshot | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Fixture / search | Information | Arrow | 1 | Refresh, Settings, More | Pending | Pending | Not run | +| Fixture / search | Text | Arrow | 1 | Refresh, Settings, More | Pending | Pending | Not run | +| Fixture / search | Settings | Arrow | 1 | Refresh, Settings, More | Pending | Pending | Not run | + +**Pass:** after Back, all baseline dashboard actions are present and target the correct page/action, with no stale child commands and no recovery refresh required. **Fail:** any missing or stale action. A successful unit test or build does not fill in a host UI result. + +## Automated contract checks + +Use a matching platform/RID from [Verify the application](../DEVELOPMENT.md#verify-the-application), for example on ARM64: + +```powershell +dotnet test .\CodexUsageDock.Tests\CodexUsageDock.Tests.csproj -c Debug -p:Platform=ARM64 -r win-arm64 -p:SelfContained=true --filter FullyQualifiedName~NavigationContractTests +dotnet test .\CodexUsageDock.Tests\CodexUsageDock.Tests.csproj -c Release -p:Platform=ARM64 -r win-arm64 -p:SelfContained=true --filter FullyQualifiedName~NavigationContractTests +``` + +These check that the real dashboard keeps the same command items, targets, and order across child-content reads and presentation refreshes; that the fixture's page shapes and refresh counter work; and that Release builds contain neither the fixture command nor its page type. Calling `GetContent()` is not host navigation. These tests should pass even when the PowerToys UI bug is present. + +If the fixture reproduces the failure while contract tests pass, investigate host command-bar restoration on ContentPage Back navigation. If only production reproduces it, investigate background content/command notifications and the Dock entry point before choosing a fix. diff --git a/scripts/local-test-policy.ps1 b/scripts/local-test-policy.ps1 new file mode 100644 index 0000000..aabe87a --- /dev/null +++ b/scripts/local-test-policy.ps1 @@ -0,0 +1,11 @@ +function Assert-LocalTestPolicy { + # Registration alone does not establish that Windows will allow unsigned + # development binaries, including DLLs loaded by the COM server, to run. + $policyPath = 'HKLM:/SYSTEM/CurrentControlSet/Control/CI/Policy' + if (-not (Test-Path -LiteralPath $policyPath -ErrorAction Stop)) { return } + $policy = Get-ItemProperty -LiteralPath $policyPath -ErrorAction Stop + $state = $policy.PSObject.Properties['VerifiedAndReputablePolicyState'] + if ($null -ne $state -and $state.Value -eq 1) { + throw 'Smart App Control is enforcing trusted code. This development workflow produces unsigned binaries that Windows can block, even with Developer Mode enabled. No installation change was made. Use a trusted signed build or a compatible isolated development environment. To restore the signed Store version, run scripts/test-local.ps1 -RestoreStore. See DEVELOPMENT.md#smart-app-control.' + } +} diff --git a/scripts/test-integration.ps1 b/scripts/test-integration.ps1 index b29270b..500f36d 100644 --- a/scripts/test-integration.ps1 +++ b/scripts/test-integration.ps1 @@ -6,12 +6,23 @@ param( [ValidateSet("Debug", "Release")] [string]$Configuration = "Debug", - [switch]$Register + [switch]$Register, + + [switch]$ArtifactsOnly ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +if ($ArtifactsOnly -and $Register) { + throw '-ArtifactsOnly cannot be combined with -Register.' +} + +if ($Register) { + . (Join-Path $PSScriptRoot 'local-test-policy.ps1') + Assert-LocalTestPolicy +} + $failures = [System.Collections.Generic.List[string]]::new() $warnings = [System.Collections.Generic.List[string]]::new() @@ -472,6 +483,20 @@ else { $null } +if ($ArtifactsOnly) { + if ($failures.Count -gt 0) { + throw "$($failures.Count) build artifact check(s) failed." + } + Write-Check -Status PASS -Message 'Build artifacts passed. Registration and activation were not checked.' + exit 0 +} + +if (-not $Register) { + . (Join-Path $PSScriptRoot 'local-test-policy.ps1') + try { Assert-LocalTestPolicy } + catch { Add-Failure $_.Exception.Message } +} + if ($Register) { if ($null -eq $generatedFacts) { Add-Failure "Registration was requested, but the generated manifest is unavailable or invalid." diff --git a/scripts/test-local.ps1 b/scripts/test-local.ps1 new file mode 100644 index 0000000..36c763a --- /dev/null +++ b/scripts/test-local.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [ValidateSet('x64', 'ARM64')] + [string]$Architecture = $(if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'ARM64' } else { 'x64' }), + [switch]$RestoreStore, + [switch]$SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'local-test-policy.ps1') +$localTestRepo = Split-Path -Parent $PSScriptRoot +$localTestPackageName = 'TheBeems.CodexUsageDock' +$localTestPackageFamily = 'TheBeems.CodexUsageDock_qye81p6cmqsf6' + +function Get-LocalTestPackage { + $packages = @(Get-AppxPackage -Name $localTestPackageName) + if ($packages.Count -gt 1) { throw 'Multiple package registrations found; resolve them before switching.' } + if ($packages.Count -eq 0) { return $null } + $package = $packages[0] + if ($package.PackageFamilyName -ne $localTestPackageFamily) { throw 'Unexpected package publisher; refusing to switch.' } + if ($package.IsDevelopmentMode) { + $trustedRoot = [IO.Path]::GetFullPath((Join-Path $localTestRepo 'CodexUsageDock/bin')).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar + if (-not [IO.Path]::GetFullPath($package.InstallLocation).StartsWith($trustedRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw 'The development registration belongs to another checkout; refusing to replace it.' + } + } + elseif ($package.SignatureKind.ToString() -ne 'Store') { throw 'The installed package is not Store-signed; refusing to replace it.' } + return $package +} + +function Stop-LocalTestProvider($Package) { + if ($null -eq $Package) { return } + $executable = Join-Path $Package.InstallLocation 'CodexUsageDock.exe' + $session = (Get-Process -Id $PID).SessionId + foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'CodexUsageDock.exe'")) { + if ($process.SessionId -eq $session -and $process.ExecutablePath -ieq $executable) { + Stop-Process -Id $process.ProcessId -Force + } + } +} + +function New-LocalTestBackup([string]$LocalData, $Package) { + $backupRoot = Join-Path $LocalData ('CodexUsageDock-development-backups/' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $backupRoot | Out-Null + $paths = @( + 'CodexUsageDock', + "Packages/$localTestPackageFamily/LocalCache/Local/CodexUsageDock", + "Packages/$localTestPackageFamily/LocalState" + ) + $copies = @() + foreach ($relative in $paths) { + $source = Join-Path $LocalData $relative + if (Test-Path -LiteralPath $source -PathType Container) { + $destination = Join-Path $backupRoot $relative + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force + $copies += [pscustomobject]@{ Source = $source; Backup = $destination } + } + } + [pscustomobject]@{ + CreatedAt = [DateTimeOffset]::Now.ToString('O') + Package = if ($null -ne $Package) { $Package | Select-Object PackageFullName, InstallLocation, IsDevelopmentMode } else { $null } + Paths = $copies + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $backupRoot 'backup.json') -Encoding utf8 + Write-Host "Data backup: $backupRoot" + return ,$copies +} + +function Restore-LocalTestData($Copies) { + foreach ($copy in $Copies) { + New-Item -ItemType Directory -Path $copy.Source -Force | Out-Null + foreach ($entry in Get-ChildItem -LiteralPath $copy.Backup -Force) { + Copy-Item -LiteralPath $entry.FullName -Destination $copy.Source -Recurse -Force + } + } +} + +function Get-LocalTestWinget { + $installer = Get-AppxPackage Microsoft.DesktopAppInstaller | Select-Object -First 1 + if ($null -eq $installer) { throw 'App Installer (winget) is required for returning to Microsoft Store.' } + $path = Join-Path $installer.InstallLocation 'winget.exe' + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'winget.exe was not found.' } + return $path +} + +function Install-LocalTestStore([string]$Winget) { + & $Winget install --id 9NFCPJXQG9FG --exact --source msstore --accept-source-agreements --accept-package-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { throw "Store installation failed (winget exit $LASTEXITCODE)." } + $installed = Get-AppxPackage -Name $localTestPackageName + if ($null -eq $installed -or $installed.IsDevelopmentMode -or $installed.SignatureKind.ToString() -ne 'Store' -or $installed.Status.ToString() -ne 'Ok') { + throw 'A healthy Store installation could not be verified.' + } +} + +function Invoke-LocalTestBuild([string]$TargetArchitecture) { + $dotnet = Join-Path $localTestRepo '.dotnet/dotnet.exe' + if (-not (Test-Path -LiteralPath $dotnet)) { $dotnet = 'dotnet' } + $rid = if ($TargetArchitecture -eq 'ARM64') { 'win-arm64' } else { 'win-x64' } + Push-Location $localTestRepo + try { + & $dotnet build ./CodexUsageDock/CodexUsageDock.csproj -c Debug "-p:Platform=$TargetArchitecture" -r $rid --self-contained true + if ($LASTEXITCODE -ne 0) { throw "Build failed (exit $LASTEXITCODE); no package was replaced." } + } + finally { Pop-Location } +} + +function Invoke-LocalTestPreflight([string]$TargetArchitecture, [switch]$Register) { + # Use a child process because the integration script returns a process exit code. + $shell = (Get-Process -Id $PID).Path + $arguments = @('-NoLogo', '-NoProfile', '-File', (Join-Path $PSScriptRoot 'test-integration.ps1'), '-Architecture', $TargetArchitecture) + $arguments += if ($Register) { '-Register' } else { '-ArtifactsOnly' } + & $shell @arguments + if ($LASTEXITCODE -ne 0) { throw "Integration check failed (exit $LASTEXITCODE)." } +} + +function Invoke-LocalTest([string]$TargetArchitecture, [switch]$UseStore, [switch]$NoBuild) { + if ($UseStore -and $NoBuild) { throw '-RestoreStore and -SkipBuild cannot be combined.' } + $package = Get-LocalTestPackage + if ($UseStore -and $null -ne $package -and -not $package.IsDevelopmentMode) { + Write-Host 'The Store version is already installed.' + return + } + if (-not $UseStore) { Assert-LocalTestPolicy } + # Check that the Store recovery command is available before removing anything. + $winget = Get-LocalTestWinget + if (-not $UseStore) { + if ($null -ne $package -and $package.IsDevelopmentMode) { Stop-LocalTestProvider $package } + if (-not $NoBuild) { Invoke-LocalTestBuild $TargetArchitecture } + Invoke-LocalTestPreflight $TargetArchitecture + } + Stop-LocalTestProvider $package + $copies = New-LocalTestBackup ([Environment]::GetFolderPath('LocalApplicationData')) $package + $removed = $false + try { + if ($null -ne $package -and ($UseStore -or -not $package.IsDevelopmentMode)) { + Write-Host "Switching the current user's package: $($package.PackageFullName)" + if ($package.IsDevelopmentMode) { + Remove-AppxPackage -Package $package.PackageFullName -PreserveApplicationData + } + else { + # PreserveApplicationData only applies to loose development registrations. + Remove-AppxPackage -Package $package.PackageFullName + } + $removed = $true + Restore-LocalTestData $copies + } + if ($UseStore) { Install-LocalTestStore $winget } + else { Invoke-LocalTestPreflight $TargetArchitecture -Register } + } + catch { + $switchError = $_ + if ($removed) { + try { + $current = Get-AppxPackage -Name $localTestPackageName + if ($null -ne $current) { + # Do not remove a package installed concurrently or a successfully registered build. + Write-Warning 'A package is registered. It was left in place; inspect it before retrying.' + } + elseif ($package.IsDevelopmentMode) { + Add-AppxPackage -Register (Join-Path $package.InstallLocation 'AppxManifest.xml') -ForceUpdateFromAnyVersion -ForceApplicationShutdown + } + else { Install-LocalTestStore $winget } + Restore-LocalTestData $copies + } + catch { Write-Warning "Automatic recovery failed: $($_.Exception.Message). The data backup was retained." } + } + throw $switchError + } + Write-Host 'Switch complete. In Command Palette, run Reload Command Palette Extension, then open Codex Usage.' + if (-not $UseStore) { Write-Host 'Return to Microsoft Store with: ./scripts/test-local.ps1 -RestoreStore' } +} + +# Dot-sourcing exposes functions for tests without building or touching registration. +if ($MyInvocation.InvocationName -ne '.') { + Invoke-LocalTest -TargetArchitecture $Architecture -UseStore:$RestoreStore -NoBuild:$SkipBuild +} diff --git a/scripts/tests/test-local.Tests.ps1 b/scripts/tests/test-local.Tests.ps1 new file mode 100644 index 0000000..c764f43 --- /dev/null +++ b/scripts/tests/test-local.Tests.ps1 @@ -0,0 +1,161 @@ +. (Join-Path $PSScriptRoot '../test-local.ps1') + +Describe 'Local test installation' { + BeforeEach { + $script:package = [pscustomobject]@{ + PackageFullName = 'TheBeems.CodexUsageDock_0.8.1.0_arm64__qye81p6cmqsf6' + PackageFamilyName = $localTestPackageFamily + InstallLocation = 'C:/Program Files/WindowsApps/CodexUsageDock' + IsDevelopmentMode = $false + SignatureKind = 'Store' + } + Mock Get-AppxPackage { $script:package } + Mock Assert-LocalTestPolicy {} + Mock Get-LocalTestWinget { 'winget.exe' } + Mock Stop-LocalTestProvider {} + Mock New-LocalTestBackup { @() } + Mock Restore-LocalTestData {} + Mock Invoke-LocalTestBuild {} + Mock Invoke-LocalTestPreflight {} + Mock Remove-AppxPackage { $script:package = $null } + Mock Add-AppxPackage {} + Mock Install-LocalTestStore {} + } + + It 'does not uninstall when the build fails' { + Mock Invoke-LocalTestBuild { throw 'Build failed' } + { Invoke-LocalTest ARM64 } | Should Throw + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + } + + It 'does not uninstall stale or invalid artifacts even when skipping the build' { + Mock Invoke-LocalTestPreflight { throw 'Stale build' } + { Invoke-LocalTest ARM64 -NoBuild } | Should Throw + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + } + + It 'refuses an incompatible security policy before any installation work' { + Mock Assert-LocalTestPolicy { throw 'Smart App Control is enforcing trusted code.' } + { Invoke-LocalTest ARM64 } | Should Throw 'Smart App Control' + Assert-MockCalled -Scope It Stop-LocalTestProvider -Times 0 -Exactly + Assert-MockCalled -Scope It Invoke-LocalTestBuild -Times 0 -Exactly + Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 0 -Exactly + Assert-MockCalled -Scope It New-LocalTestBackup -Times 0 -Exactly + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + Assert-MockCalled -Scope It Install-LocalTestStore -Times 0 -Exactly + } + + It 'also checks the policy when reusing an existing build' { + Mock Assert-LocalTestPolicy { throw 'Smart App Control is enforcing trusted code.' } + { Invoke-LocalTest ARM64 -NoBuild } | Should Throw 'Smart App Control' + Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 0 -Exactly + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + } + + It 'does not uninstall if the backup fails' { + Mock New-LocalTestBackup { throw 'Backup unavailable' } + { Invoke-LocalTest ARM64 -NoBuild } | Should Throw + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + } + + It 'replaces the Store package only after validation and backup' { + Mock Remove-AppxPackage { + Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 1 -Exactly -ParameterFilter { -not $Register } + Assert-MockCalled -Scope It New-LocalTestBackup -Times 1 -Exactly + $script:package = $null + } + Invoke-LocalTest ARM64 -NoBuild + Assert-MockCalled -Scope It Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { -not $PreserveApplicationData } + Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 1 -Exactly -ParameterFilter { $Register } + Assert-MockCalled -Scope It Restore-LocalTestData -Times 1 -Exactly + } + + It 'recovers the Store registration when local registration fails' { + Mock Invoke-LocalTestPreflight { if ($Register) { throw 'Registration failed' } } + { Invoke-LocalTest ARM64 -NoBuild } | Should Throw + Assert-MockCalled -Scope It Install-LocalTestStore -Times 1 -Exactly + } + + It 'refreshes an existing repository development package in place' { + $script:package.IsDevelopmentMode = $true + $script:package.InstallLocation = Join-Path $localTestRepo 'CodexUsageDock/bin/ARM64/Debug' + Invoke-LocalTest ARM64 -NoBuild + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 1 -Exactly -ParameterFilter { $Register } + } + + It 'refuses a development package from another checkout before mutation' { + $script:package.IsDevelopmentMode = $true + $script:package.InstallLocation = Join-Path $TestDrive 'another-checkout' + { Invoke-LocalTest ARM64 -NoBuild } | Should Throw + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + Assert-MockCalled -Scope It Stop-LocalTestProvider -Times 0 -Exactly + } + + It 'preserves development data when returning to the Store' { + $script:package.IsDevelopmentMode = $true + $script:package.InstallLocation = Join-Path $localTestRepo 'CodexUsageDock/bin/ARM64/Debug' + Invoke-LocalTest ARM64 -UseStore + Assert-MockCalled -Scope It Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { $PreserveApplicationData } + Assert-MockCalled -Scope It Install-LocalTestStore -Times 1 -Exactly + Assert-MockCalled -Scope It Assert-LocalTestPolicy -Times 0 -Exactly + } + + It 'does nothing when the Store version is already registered' { + Invoke-LocalTest ARM64 -UseStore + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + Assert-MockCalled -Scope It New-LocalTestBackup -Times 0 -Exactly + } +} + +Describe 'Smart App Control development policy check' { + BeforeEach { + Mock Test-Path { $true } + Mock Get-ItemProperty { [pscustomobject]@{ VerifiedAndReputablePolicyState = 1 } } + } + + It 'rejects enforcement without modifying policy' { + { Assert-LocalTestPolicy } | Should Throw 'Smart App Control' + } + + It 'allows evaluation mode' { + Mock Get-ItemProperty { [pscustomobject]@{ VerifiedAndReputablePolicyState = 2 } } + { Assert-LocalTestPolicy } | Should Not Throw + } + + It 'allows disabled policy' { + Mock Get-ItemProperty { [pscustomobject]@{ VerifiedAndReputablePolicyState = 0 } } + { Assert-LocalTestPolicy } | Should Not Throw + } + + It 'supports Windows versions without the policy key' { + Mock Test-Path { $false } + { Assert-LocalTestPolicy } | Should Not Throw + Assert-MockCalled -Scope It Get-ItemProperty -Times 0 -Exactly + } + + It 'supports a policy key without the Smart App Control value' { + Mock Get-ItemProperty { [pscustomobject]@{} } + { Assert-LocalTestPolicy } | Should Not Throw + } + + It 'does not silently approve an unreadable policy' { + Mock Get-ItemProperty { throw 'Access denied' } + { Assert-LocalTestPolicy } | Should Throw 'Access denied' + } +} + +Describe 'Local data backup' { + It 'retains settings and nested history independently of the application directory' { + $root = Join-Path $TestDrive 'data' + $source = Join-Path $root 'CodexUsageDock/contexts/test' + New-Item -ItemType Directory -Path $source -Force | Out-Null + 'history fixture' | Set-Content (Join-Path $source 'weekly-usage-history.json') + $copies = New-LocalTestBackup $root $null + 'changed history' | Set-Content (Join-Path $source 'weekly-usage-history.json') + Restore-LocalTestData $copies + (Get-Content (Join-Path $source 'weekly-usage-history.json')) | Should Be 'history fixture' + $copies.Count | Should Be 1 + $copies[0].Backup.StartsWith((Join-Path $root 'CodexUsageDock-development-backups')) | Should Be $true + } +} From bdc60515f59c4a1d94ed8827628495aa518bb9dd Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:51:35 +0200 Subject: [PATCH 2/3] Link unreleased changes to PR 29 --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ce23e..3388781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,18 +11,18 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ### Changed -- Show remaining allowance in dashboard bars and the weekly chart, restore daily token bars and their right-hand axis by default, and stop forecasts at the estimated limit. Prioritize weekly status and the chart, use full-width single windows, improve column separation and chart labels, and replace the Details toggle with Usage information in the More menu. -- Compact dashboard headings and spacing, match single-window bar thickness to paired windows, and shorten Usage information. Use readable text rows with shared quota names and consistent English local dates in the text alternative. +- Show remaining allowance in dashboard bars and the weekly chart, restore daily token bars and their right-hand axis by default, and stop forecasts at the estimated limit. Prioritize weekly status and the chart, use full-width single windows, improve column separation and chart labels, and replace the Details toggle with Usage information in the More menu. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) +- Compact dashboard headings and spacing, match single-window bar thickness to paired windows, and shorten Usage information. Use readable text rows with shared quota names and consistent English local dates in the text alternative. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) ### Fixed -- Detect Smart App Control enforcement before unsigned local-test installation or registration, preserving the current installation and explaining signed Store recovery. -- Deduplicate default quotas regardless of their API position, avoid narrow token-table cells that split full numbers, omit empty forecast sections, and use the same rounded forecast labels throughout the dashboard, chart, and information page. +- Detect Smart App Control enforcement before unsigned local-test installation or registration, preserving the current installation and explaining signed Store recovery. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) +- Deduplicate default quotas regardless of their API position, avoid narrow token-table cells that split full numbers, omit empty forecast sections, and use the same rounded forecast labels throughout the dashboard, chart, and information page. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) ### Added -- Add a Debug-only navigation test page, automated action-contract checks, and a repeatable host UI procedure for disappearing footer actions after Back. -- Add a one-command local test workflow for the current Windows account, with data backups, validated development registration, and a command to return to Microsoft Store. +- Add a Debug-only navigation test page, automated action-contract checks, and a repeatable host UI procedure for disappearing footer actions after Back. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) +- Add a one-command local test workflow for the current Windows account, with data backups, validated development registration, and a command to return to Microsoft Store. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) ## [0.8.1] - 2026-09-15 From 68e77b20a33fbc2f8035754366919fc966c50cc6 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:42:48 +0200 Subject: [PATCH 3/3] Fix local-test dependency and latest chart observation --- CHANGELOG.md | 1 + CodexUsageDock.Tests/UsageDataTests.cs | 42 +++++++++++++++++++++++++ CodexUsageDock/WeeklyUsageTrendChart.cs | 6 ++-- DEVELOPMENT.md | 2 +- scripts/test-local.ps1 | 4 +-- scripts/tests/test-local.Tests.ps1 | 36 +++++++++++++++++++++ 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3388781..dcc34bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ### Fixed +- Require App Installer only when the local test workflow needs Store installation or recovery, and retain the latest observation and current-value label when weekly chart segments exceed the rendering budget. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) - Detect Smart App Control enforcement before unsigned local-test installation or registration, preserving the current installation and explaining signed Store recovery. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) - Deduplicate default quotas regardless of their API position, avoid narrow token-table cells that split full numbers, omit empty forecast sections, and use the same rounded forecast labels throughout the dashboard, chart, and information page. ([PR #29](https://github.com/TheBeems/CodexUsageDock/pull/29)) diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 71c5df0..f830d3e 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -1079,6 +1079,48 @@ public void WeeklyTrendChartBoundsMinuteHistory() Assert.True(result.ImageUrl.Length < 80000, result.ImageUrl.Length.ToString(CultureInfo.InvariantCulture)); } + [Theory] + [InlineData(181, 1)] + [InlineData(360, 1)] + [InlineData(100, 2)] + public void WeeklyTrendChartRetainsLatestObservationWhenSegmentsExceedPointBudget(int segmentCount, int samplesPerSegment) + { + var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var sampleCount = segmentCount * samplesPerSegment; + var samples = Enumerable.Range(0, sampleCount) + .Select(index => new UsageHistoryEntry( + windowStart.AddMinutes(index / samplesPerSegment * 20 + index % samplesPerSegment), + 100 - index * 100d / (sampleCount - 1))) + .ToArray(); + var now = samples[^1].RecordedAt; + var chart = WeeklyUsageTrendChartRenderer.Create( + samples, + new RateLimitWindow(100, 10080, reset), + now, + TimeSpan.FromMinutes(5), + forecast: null, + culture: CultureInfo.InvariantCulture, + timeZone: TimeZoneInfo.Utc); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + var label = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "current"); + var circles = svg.Descendants(Svg + "circle").ToArray(); + var latest = Assert.Single(circles, circle => circle.Attribute("r")?.Value == "3"); + var nowMarker = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "now"); + var zeroGridline = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-grid")?.Value == "remaining-percent" + && line.Attribute("data-value")?.Value == "0"); + + Assert.Equal("0%", label.Attribute("data-axis-label")?.Value); + Assert.Equal(nowMarker.Attribute("x1")!.Value, latest.Attribute("cx")!.Value); + Assert.Equal(zeroGridline.Attribute("y1")!.Value, latest.Attribute("cy")!.Value); + Assert.Contains("100% to 0%", result.AltText, StringComparison.Ordinal); + Assert.Empty(svg.Descendants(Svg + "polyline")); + Assert.InRange(circles.Length, 2, WeeklyUsageTrendChartRenderer.MaximumRenderedPoints); + Assert.Equal(circles.Length, circles.Select(circle => circle.Attribute("cx")!.Value).Distinct().Count()); + } + [Fact] public void UsagePaceWarnsWhenAllowanceRunsFarAheadOfElapsedTime() { diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index c924b82..d39d5df 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -132,10 +132,12 @@ private static List DownsampleSegments( var minimumPoints = segments.Select(segment => segment.Length > 1 ? 2 : 1).ToArray(); if (minimumPoints.Sum() > MaximumRenderedPoints) { - return Enumerable.Range(0, MaximumRenderedPoints) + var selectedSegments = Math.Min(segments.Count, MaximumRenderedPoints); + return Enumerable.Range(0, selectedSegments) .Select(slot => { - var segment = segments[(int)(slot * segments.Count / (double)MaximumRenderedPoints)]; + // Include both endpoint segments so the current marker keeps the latest sample. + var segment = segments[(int)(slot * (segments.Count - 1) / (double)(selectedSegments - 1))]; return new[] { segment[^1] }; }) .ToList(); diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 80e3177..3c183ba 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -104,7 +104,7 @@ Return to the version currently available through Microsoft Store with: .\scripts\test-local.ps1 -RestoreStore ``` -The return command backs up the current data, unregisters the development build with data preservation, and installs the official Store product using App Installer (`winget`). It verifies the Store signature and package health. It needs Store/network access and does not promise the exact version installed before testing. A failed switch attempts recovery when no package remains registered; the backup is retained even if recovery fails. If a package is already registered after a partial failure, it is left in place for inspection. +The return command backs up the current data, unregisters the development build with data preservation, and installs the official Store product using App Installer (`winget`). It verifies the Store signature and package health. It needs Store/network access and does not promise the exact version installed before testing. App Installer is also required before replacing a Store installation so recovery is available; registering a development build with no installed package or refreshing this checkout's development registration does not require it. A failed switch attempts recovery when no package remains registered; the backup is retained even if recovery fails. If a package is already registered after a partial failure, it is left in place for inspection. Settings and history remain shared with the Store version; these backups do not include Codex credentials or session logs. Test migrations against isolated data first. [Windows only supports `-PreserveApplicationData` for development registrations](https://learn.microsoft.com/en-us/powershell/module/appx/remove-appxpackage#-preserveapplicationdata), so the Store-to-development switch explicitly backs up and restores the application's data directories. diff --git a/scripts/test-local.ps1 b/scripts/test-local.ps1 index 36c763a..ba4b157 100644 --- a/scripts/test-local.ps1 +++ b/scripts/test-local.ps1 @@ -122,8 +122,8 @@ function Invoke-LocalTest([string]$TargetArchitecture, [switch]$UseStore, [switc return } if (-not $UseStore) { Assert-LocalTestPolicy } - # Check that the Store recovery command is available before removing anything. - $winget = Get-LocalTestWinget + # Store installation or recovery must be available before replacing a package. + $winget = if ($UseStore -or ($null -ne $package -and -not $package.IsDevelopmentMode)) { Get-LocalTestWinget } else { $null } if (-not $UseStore) { if ($null -ne $package -and $package.IsDevelopmentMode) { Stop-LocalTestProvider $package } if (-not $NoBuild) { Invoke-LocalTestBuild $TargetArchitecture } diff --git a/scripts/tests/test-local.Tests.ps1 b/scripts/tests/test-local.Tests.ps1 index c764f43..c85834c 100644 --- a/scripts/tests/test-local.Tests.ps1 +++ b/scripts/tests/test-local.Tests.ps1 @@ -76,14 +76,48 @@ Describe 'Local test installation' { Assert-MockCalled -Scope It Install-LocalTestStore -Times 1 -Exactly } + It 'registers a development package without App Installer when no package is installed' { + Mock Get-AppxPackage {} + Mock Get-LocalTestWinget { throw 'App Installer unavailable' } + Invoke-LocalTest ARM64 -NoBuild + Assert-MockCalled -Scope It Get-LocalTestWinget -Times 0 -Exactly + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 1 -Exactly -ParameterFilter { $Register } + } + It 'refreshes an existing repository development package in place' { $script:package.IsDevelopmentMode = $true $script:package.InstallLocation = Join-Path $localTestRepo 'CodexUsageDock/bin/ARM64/Debug' + Mock Get-LocalTestWinget { throw 'App Installer unavailable' } Invoke-LocalTest ARM64 -NoBuild + Assert-MockCalled -Scope It Get-LocalTestWinget -Times 0 -Exactly Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly Assert-MockCalled -Scope It Invoke-LocalTestPreflight -Times 1 -Exactly -ParameterFilter { $Register } } + It 'requires App Installer before replacing the Store package' { + Mock Get-LocalTestWinget { throw 'App Installer unavailable' } + { Invoke-LocalTest ARM64 -NoBuild } | Should Throw 'App Installer unavailable' + Assert-MockCalled -Scope It Stop-LocalTestProvider -Times 0 -Exactly + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + } + + It 'requires App Installer before removing a development package to restore the Store' { + $script:package.IsDevelopmentMode = $true + $script:package.InstallLocation = Join-Path $localTestRepo 'CodexUsageDock/bin/ARM64/Debug' + Mock Get-LocalTestWinget { throw 'App Installer unavailable' } + { Invoke-LocalTest ARM64 -UseStore } | Should Throw 'App Installer unavailable' + Assert-MockCalled -Scope It Stop-LocalTestProvider -Times 0 -Exactly + Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly + } + + It 'requires App Installer to restore the Store with no registered package' { + Mock Get-AppxPackage {} + Mock Get-LocalTestWinget { throw 'App Installer unavailable' } + { Invoke-LocalTest ARM64 -UseStore } | Should Throw 'App Installer unavailable' + Assert-MockCalled -Scope It Install-LocalTestStore -Times 0 -Exactly + } + It 'refuses a development package from another checkout before mutation' { $script:package.IsDevelopmentMode = $true $script:package.InstallLocation = Join-Path $TestDrive 'another-checkout' @@ -102,7 +136,9 @@ Describe 'Local test installation' { } It 'does nothing when the Store version is already registered' { + Mock Get-LocalTestWinget { throw 'App Installer unavailable' } Invoke-LocalTest ARM64 -UseStore + Assert-MockCalled -Scope It Get-LocalTestWinget -Times 0 -Exactly Assert-MockCalled -Scope It Remove-AppxPackage -Times 0 -Exactly Assert-MockCalled -Scope It New-LocalTestBackup -Times 0 -Exactly }