Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality).

## [Unreleased]

## [0.8.7] - 2026-10-09

### Added

- Add a separate Time remaining bar that counts from 100% down to 0% until the scheduled quota reset, independently of remaining allowance. ([PR #34](https://github.com/TheBeems/CodexUsageDock/pull/34))

### Changed

- Group allowance, conditional weekly forecast and Next scheduled reset into a compact dashboard. Keep the contrasting dated estimate above the chart, its quota forecast basis below it, and local token-log details in Usage information. ([PR #34](https://github.com/TheBeems/CodexUsageDock/pull/34))

### Fixed

- Apply Show five-hour limit to dashboard windows and missing-data messages. Improve chart legends, punctuation, independent scales, reset markers and token-bar contrast; keep the dated forecast endpoint from obscuring the plot. ([PR #34](https://github.com/TheBeems/CodexUsageDock/pull/34))

## [0.8.6] - 2026-10-04

### Fixed
Expand Down
41 changes: 41 additions & 0 deletions CodexUsageDock.Tests/ProviderDockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,47 @@ public sealed class ProviderDockTests : IDisposable
private readonly TestEnvironment _environment = new();
public void Dispose() => _environment.Dispose();

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task FiveHourSettingUpdatesDashboardImmediatelyWithoutReadingUsageAgain(bool reported)
{
var now = DateTimeOffset.UtcNow;
var reads = 0;
var snapshot = new CodexUsageSnapshot(reported ? new(25, 300, now.AddHours(4)) : null,
new(33, 10080, now.AddDays(6)), null, null, null, now, UsageDataSource.AppServer, null);
using var service = _environment.CreateService(_ =>
{
Interlocked.Increment(ref reads);
return Task.FromResult(snapshot);
}, () => CodexUsageSnapshot.Loading, clock: () => now);
var settings = _environment.CreateSettings();
using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now);
await service.RefreshAsync().WaitAsync(TimeSpan.FromSeconds(5));
var readsBeforeSettings = Volatile.Read(ref reads);
var page = Assert.IsType<CodexUsageDockPage>(provider.TopLevelCommands()[0].Command);
var content = Assert.IsType<FormContent>(Assert.Single(page.GetContent()));
Assert.Contains("5-hour", content.DataJson, StringComparison.Ordinal);

SubmitSettings(settings, (ShowFiveHourLimitKey, "false"));
using (var hidden = JsonDocument.Parse(content.DataJson))
{
var group = hidden.RootElement.GetProperty("quotaGroups")[0];
Assert.Equal("Weekly", Assert.Single(group.GetProperty("windows").EnumerateArray()).GetProperty("title").GetString());
Assert.False(group.GetProperty("hasInactiveWindows").GetBoolean());
}
Assert.Equal(readsBeforeSettings, Volatile.Read(ref reads));

SubmitSettings(settings, (ShowFiveHourLimitKey, "true"));
using var restored = JsonDocument.Parse(content.DataJson);
var restoredGroup = restored.RootElement.GetProperty("quotaGroups")[0];
if (reported)
Assert.Contains(restoredGroup.GetProperty("windows").EnumerateArray(), window => window.GetProperty("title").GetString() == "5-hour");
else
Assert.Contains("5-hour", restoredGroup.GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal);
Assert.Equal(readsBeforeSettings, Volatile.Read(ref reads));
}

[Fact]
public async Task TogglingAlertsOffAndOnWithoutARefreshEstablishesANewBaseline()
{
Expand Down
202 changes: 179 additions & 23 deletions CodexUsageDock.Tests/UsageDashboardTests.cs

Large diffs are not rendered by default.

30 changes: 16 additions & 14 deletions CodexUsageDock.Tests/UsageDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
}

[Fact]
public void ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary()

Check warning on line 78 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var weekly = new RateLimitWindow(3, 10080, DateTimeOffset.Now.AddDays(7));

Expand All @@ -95,7 +95,7 @@
}

[Fact]
public void Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive()

Check warning on line 98 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var now = new DateTimeOffset(2026, 7, 12, 14, 0, 0, TimeSpan.Zero);
var snapshot = CodexUsageSnapshot.Loading with
Expand All @@ -112,7 +112,7 @@
}

[Fact]
public void MissingFiveHourWindow_CanBeDescribedAsInactive()

Check warning on line 115 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.MissingFiveHourWindow_CanBeDescribedAsInactive() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var text = CodexUsageDockPage.FormatWindow("5-hour window", null, DateTimeOffset.Now, "Currently inactive");

Expand All @@ -121,7 +121,7 @@
}

[Fact]
public void AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand()

Check warning on line 124 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var workingDirectory = CodexAppServerReader.GetSafeWorkingDirectory("codex.exe");

Expand Down Expand Up @@ -207,7 +207,7 @@
using var service = _environment.CreateService();
using var page = new CodexUsageDockPage(service, _environment.CreateSettings());

Assert.Equal("0.8.6", CodexUsageDockMetadata.Version);
Assert.Equal("0.8.7", CodexUsageDockMetadata.Version);
Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title);
}

Expand Down Expand Up @@ -236,12 +236,14 @@

Assert.Equal("AdaptiveCard", template.RootElement.GetProperty("type").GetString());
Assert.Contains("\"type\": \"Image\"", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("Remaining", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("remainingHeadline", 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("usageComparison", main.TemplateJson, StringComparison.Ordinal);
Assert.DoesNotContain("elapsedBarUrl", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("timeRemainingBarUrl", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("weeklyTrendAvailable", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal);
Assert.DoesNotContain("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("weeklyForecastStatus", main.TemplateJson, StringComparison.Ordinal);
Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent()));
}
Expand Down Expand Up @@ -278,13 +280,13 @@
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("remainingBarUrl").GetString(), StringComparison.Ordinal);
Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("elapsedBarUrl").GetString(), StringComparison.Ordinal);
Assert.Equal("20% used · 20% of window elapsed", DashboardWindow(root, "5-hour").GetProperty("usageComparison").GetString());
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.Equal("2% used · 14% of week elapsed", DashboardWindow(root, "Weekly").GetProperty("usageComparison").GetString());
Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean());
Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal);
Assert.Contains("Solid line connects sampled values across measurement gaps", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal);
Assert.StartsWith("Forecast: recent 6 h only.", root.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal);
Assert.StartsWith("Quota 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("remainingColor").GetString());
Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal);
Expand Down Expand Up @@ -383,7 +385,7 @@

using var document = JsonDocument.Parse(data);

Assert.Contains("3 usable weeks", document.RootElement.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal);
Assert.Contains("3 past weeks", document.RootElement.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal);
}

[Fact]
Expand Down Expand Up @@ -479,7 +481,7 @@
[Fact]
public void UsageProgressBarCreatesSvgDataUri()
{
var bar = UsageDashboardCard.CreateProgressBarImageUrl(75, UsageBarPalette.Remaining);
var bar = UsageDashboardCard.CreateProgressBarImageUrl(75);

Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal);
Assert.Contains("%3Csvg", bar, StringComparison.OrdinalIgnoreCase);
Expand All @@ -489,7 +491,7 @@
[Fact]
public void UsageProgressBarHandlesNonFinitePercentage()
{
var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN, UsageBarPalette.Remaining);
var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN);

Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal);
Assert.DoesNotContain("NaN", bar, StringComparison.Ordinal);
Expand Down Expand Up @@ -527,7 +529,7 @@
Assert.Contains(lines, line => line.Attribute("stroke-dasharray") is null);
Assert.Contains(lines, line => line.Attribute("stroke-dasharray")?.Value == "5 4");
Assert.Equal(3, root.Descendants(Svg + "rect").Count(rect => rect.Attribute("data-series")?.Value == "daily-tokens"));
Assert.Equal(["1M", "500K"], root.Descendants(Svg + "g")
Assert.Equal(["1M", "500K", "0"], root.Descendants(Svg + "g")
.Where(group => group.Attribute("data-axis")?.Value == "tokens")
.Select(group => group.Attribute("data-axis-label")!.Value));
Assert.Contains("Solid line connects sampled values across measurement gaps", result.AltText, StringComparison.Ordinal);
Expand Down Expand Up @@ -569,7 +571,7 @@
var root = document.RootElement;
var chart = ParseSvg(root.GetProperty("weeklyTrendChartUrl").GetString()!);

Assert.Contains("partial local tokens per day", root.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
Assert.Contains("incomplete session logs", root.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);
Assert.Contains("Local token data is partial", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal);
Assert.Single(chart.Descendants(Svg + "rect"), rect => rect.Attribute("data-series")?.Value == "daily-tokens");
}
Expand Down Expand Up @@ -1220,7 +1222,7 @@
}

[Fact]
public void ParseResetCredits_UsesAvailableCountAndExpiryDetails()

Check warning on line 1225 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseResetCredits_UsesAvailableCountAndExpiryDetails() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var json = JsonDocument.Parse("""
{
Expand All @@ -1243,7 +1245,7 @@
}

[Fact]
public void ParseResetCredits_PreservesCountWhenDetailsAreNull()

Check warning on line 1248 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseResetCredits_PreservesCountWhenDetailsAreNull() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var json = JsonDocument.Parse("""{ "rateLimitResetCredits": { "availableCount": 3, "credits": null } }""");

Expand Down Expand Up @@ -1335,7 +1337,7 @@
}

[Fact]
public void ParseCredits_HandlesBalanceAndUnlimitedAccounts()

Check warning on line 1340 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseCredits_HandlesBalanceAndUnlimitedAccounts() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var result = JsonDocument.Parse("{}");
using var balanceLimits = JsonDocument.Parse("""{ "credits": { "hasCredits": true, "unlimited": false, "balance": "10.00" } }""");
Expand Down Expand Up @@ -1377,7 +1379,7 @@
[InlineData(null, null, "-- resets")]
[InlineData(0, null, "0 resets")]
[InlineData(2, "10.00", "2 resets · 10.00")]
public void DockSummary_FormatsAvailableData(int? resetCount, string? balance, string expected)

Check warning on line 1382 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DockSummary_FormatsAvailableData(int?, string?, string) (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var snapshot = new CodexUsageSnapshot(
null,
Expand Down Expand Up @@ -1438,7 +1440,7 @@
}

[Fact]
public void DetailFormatter_ReportsMissingExpiryRows()

Check warning on line 1443 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DetailFormatter_ReportsMissingExpiryRows() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var resets = new RateLimitResetCredits(
2,
Expand All @@ -1464,7 +1466,7 @@
}

[Fact]
public void DashboardSummary_WarnsWhenPrimaryLimitIsLow()

Check warning on line 1469 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DashboardSummary_WarnsWhenPrimaryLimitIsLow() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var now = new DateTimeOffset(2026, 7, 12, 14, 0, 0, TimeSpan.Zero);
var snapshot = new CodexUsageSnapshot(
Expand Down Expand Up @@ -2306,9 +2308,9 @@
var adaptive = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, true, history);
var currentOnly = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, false, history);

Assert.Contains("3 usable weeks", adaptive.Status, StringComparison.Ordinal);
Assert.Contains("3 past weeks", adaptive.Status, StringComparison.Ordinal);
Assert.True(adaptive.Forecast.EndsAt < currentOnly.Forecast.EndsAt);
Assert.Equal("Forecast: current pace only.", currentOnly.Status);
Assert.Equal("Quota forecast: current pace only.", currentOnly.Status);
}

[Fact]
Expand Down
83 changes: 81 additions & 2 deletions CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ public void ResetAndNowLabelsFitInsideTheHeaderWithoutOverlapping()
var labels = svg.Descendants(Svg + "g")
.Where(group => group.Attribute("data-axis")?.Value is "marker" or "reset-detected")
.Select(LabelBounds).ToArray();
Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "NOW");
Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "Now");
Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "Next reset");
Assert.DoesNotContain(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "START");
Assert.All(labels, bounds => Assert.InRange(bounds.Top + bounds.Height, 0, PlotTop(svg)));
AssertNonOverlapping(labels);
AssertNonOverlapping([.. labels, .. LegendLabels(svg).Select(LabelBounds)]);
}

[Fact]
Expand All @@ -105,16 +107,90 @@ public void DenseCalendarLabelsKeepEveryDateWithoutOverlapping()
Assert.All(dayLabels, group => Assert.Equal(2, group.Elements(Svg + "g").Count()));
var visibleLabels = dayLabels.SelectMany(group => group.Elements(Svg + "path").Any()
? new[] { group } : group.Elements(Svg + "g")).Select(LabelBounds).ToArray();
Assert.All(visibleLabels, bounds =>
{
Assert.InRange(bounds.Left, 0, WeeklyUsageTrendChartRenderer.Width - bounds.Width);
Assert.InRange(bounds.Top + bounds.Height, 0, WeeklyUsageTrendChartRenderer.Height - 4);
});
AssertNonOverlapping(visibleLabels);
}

[Fact]
public void LegendExplainsObservedForecastAndIndependentTokenSeries()
{
var svg = Parse(CreateScenario());
Assert.Equal(["Quota remaining (%)", "Forecast", "Local tokens/day (right axis)"],
LegendLabels(svg).Select(group => group.Attribute("data-axis-label")!.Value));
var observed = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-legend-series")?.Value == "remaining");
Assert.Null(observed.Attribute("stroke-dasharray"));
var forecast = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-legend-series")?.Value == "forecast");
Assert.Equal("5 4", forecast.Attribute("stroke-dasharray")?.Value);
Assert.Single(svg.Descendants(Svg + "rect"), rect => rect.Attribute("data-legend-series")?.Value == "daily-tokens");
AssertNonOverlapping(LegendLabels(svg).Select(LabelBounds).ToArray());
}

[Fact]
public void EveryScenarioLabelRendersItsPunctuationWithoutReplacementCharacters()
{
var svg = Parse(CreateScenario());
Assert.All(svg.Descendants(Svg + "g").Where(group => group.Attribute("data-rendered-label") is not null),
group => Assert.DoesNotContain("?", group.Attribute("data-rendered-label")!.Value, StringComparison.Ordinal));
}

[Fact]
public void MissingForecastAndTokenDataDoNotAdvertiseAbsentSeries()
{
var reset = Now.AddDays(6);
var svg = Parse(Create([new(Now.AddHours(-1), 80, reset, 10080), new(Now, 75, reset, 10080)], reset));
Assert.Equal("Quota remaining (%)", Assert.Single(LegendLabels(svg)).Attribute("data-axis-label")?.Value);
Assert.DoesNotContain(svg.Descendants(), element => element.Attribute("data-legend-series")?.Value is "forecast" or "daily-tokens");
Assert.DoesNotContain(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens");
}

[Fact]
public void FutureAreaBeginsAtNowAndKeepsTheNextResetVisible()
{
var svg = Parse(CreateScenario());
var now = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "now");
var future = Assert.Single(svg.Descendants(Svg + "rect"), rect => rect.Attribute("data-area")?.Value == "future");
Assert.Equal(now.Attribute("x1")?.Value, future.Attribute("x")?.Value);
Assert.Equal(now.Attribute("y1")?.Value, future.Attribute("y")?.Value);
Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "Next reset");
Assert.All(ResetMarkers(svg), reset => Assert.Equal("#F2C94C", reset.Attribute("stroke")?.Value));
}

[Fact]
public void BillionTokenScaleUsesACompactLabelAtTheTopGridline()
{
var svg = Parse(CreateScenario());
var topTokenLabel = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens"
&& group.Attribute("data-axis-label")?.Value == "1B");
Assert.Equal(PlotTop(svg) - ChartLabelGlyphs.Height / 2, LabelBounds(topTokenLabel).Top);
var zeroTokenLabel = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens"
&& group.Attribute("data-axis-label")?.Value == "0");
var zeroGridline = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-grid")?.Value == "remaining-percent"
&& line.Attribute("data-value")?.Value == "0");
Assert.Equal(double.Parse(zeroGridline.Attribute("y1")!.Value, CultureInfo.InvariantCulture) - ChartLabelGlyphs.Height / 2,
LabelBounds(zeroTokenLabel).Top);
}

[Theory]
[InlineData(1)]
[InlineData(3)]
[InlineData(5.9)]
public void EstimatedLimitPointKeepsItsDateWithoutARepeatedLabelCoveringThePlot(double daysAhead)
{
var reset = Now.AddDays(6);
var svg = Parse(Create([new(Now.AddHours(-1), 67, reset, 10080), new(Now, 66, reset, 10080)],
reset, new(Now.AddDays(daysAhead), 0, true)));
Assert.DoesNotContain(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "estimated-limit");
var point = Assert.Single(svg.Descendants(Svg + "circle"), circle => circle.Attribute("data-marker")?.Value == "estimated-limit");
var forecast = Assert.Single(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null);
var endpoint = forecast.Attribute("points")!.Value.Split(' ')[^1].Split(',');
Assert.Equal(endpoint[0], point.Attribute("cx")!.Value);
Assert.Equal(endpoint[1], point.Attribute("cy")!.Value);
Assert.Contains("Estimated limit:", point.Element(Svg + "title")?.Value, StringComparison.Ordinal);
Assert.Contains("actual usage may differ", point.Element(Svg + "title")?.Value, StringComparison.Ordinal);
}

private static WeeklyUsageTrendChart CreateScenario()
Expand Down Expand Up @@ -164,6 +240,9 @@ private static XElement[] ObservedLines(XDocument svg) => svg.Descendants(Svg +
private static XElement[] ResetMarkers(XDocument svg) => svg.Descendants(Svg + "line")
.Where(line => line.Attribute("data-marker")?.Value == "quota-reset").ToArray();

private static XElement[] LegendLabels(XDocument svg) => svg.Descendants(Svg + "g")
.Where(group => group.Attribute("data-axis")?.Value == "legend").ToArray();

private static double PlotTop(XDocument svg) => double.Parse(svg.Descendants(Svg + "line")
.Single(line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "100")
.Attribute("y1")!.Value, CultureInfo.InvariantCulture);
Expand Down
Loading