From d4683d630f5fcc0055b09abfd8f588815898ed3f Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 12 Jul 2026 22:15:57 -0700 Subject: [PATCH] Add projection tooltips and harden Codex CLI selection --- Resources/Info.plist | 4 +- .../CodexToolbar/App/CodexToolbarApp.swift | 2 +- .../CodexToolbar/Models/CodexRateLimits.swift | 63 +++++- .../Networking/CodexAppServerClient.swift | 23 +- .../CodexToolbar/Support/RateLimitStore.swift | 10 +- .../Views/StatusMenuContentView.swift | 207 ++++++++++++++---- .../CodexAppServerClientTests.swift | 22 +- .../RateLimitCardViewDataTests.swift | 53 +++++ .../RateLimitStoreTests.swift | 76 +++++++ 9 files changed, 397 insertions(+), 63 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index d57f9c3..9ece575 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.15 + 0.1.16 CFBundleVersion - 15 + 16 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/CodexToolbar/App/CodexToolbarApp.swift b/Sources/CodexToolbar/App/CodexToolbarApp.swift index f9b08c8..4275513 100644 --- a/Sources/CodexToolbar/App/CodexToolbarApp.swift +++ b/Sources/CodexToolbar/App/CodexToolbarApp.swift @@ -949,7 +949,7 @@ enum AppVersion { return version } - return "0.1.15" + return "0.1.16" }() private static func developmentVersionFromSourceInfoPlist() -> String? { diff --git a/Sources/CodexToolbar/Models/CodexRateLimits.swift b/Sources/CodexToolbar/Models/CodexRateLimits.swift index 843cbd5..5eb5f1f 100644 --- a/Sources/CodexToolbar/Models/CodexRateLimits.swift +++ b/Sources/CodexToolbar/Models/CodexRateLimits.swift @@ -194,6 +194,14 @@ enum RateLimitProgressState: Equatable, Sendable { } } +enum RateLimitProjectionGraphElement: Equatable, Sendable { + case pace + case current + case currentAndProjectedEmpty + case reset + case projectedEmpty +} + struct RateLimitProjectionViewData: Equatable, Sendable { enum State: Equatable, Sendable { case resetFirst @@ -213,6 +221,10 @@ struct RateLimitProjectionViewData: Equatable, Sendable { let projectedEmptyPosition: Double let summaryText: String let detailText: String + let paceTooltipText: String + let currentTooltipText: String + let resetTooltipText: String + let projectedEmptyTooltipText: String let accessibilityLabel: String init?( @@ -278,6 +290,18 @@ struct RateLimitProjectionViewData: Equatable, Sendable { locale: locale, timeZone: timeZone ) + let resetText = RateLimitFormatter.absoluteResetText( + for: resetDate, + now: now, + calendar: calendar, + locale: locale, + timeZone: timeZone + ) + let paceTooltipText = Self.paceTooltipText( + dailyUsePercent: dailyUsePercent, + windowDurationMins: windowDurationMins, + locale: locale + ) let summaryText = runsOutBeforeReset ? "Projected empty before reset" : "Reset comes first" let detailText = runsOutBeforeReset ? "Empty \(projectedText) at pace" @@ -295,7 +319,26 @@ struct RateLimitProjectionViewData: Equatable, Sendable { projectedEmptyPosition = Self.clampedPosition(for: projectedEmptyDate, start: windowStartDate, durationSeconds: chartDurationSeconds) self.summaryText = summaryText self.detailText = detailText - accessibilityLabel = "\(windowLabel) pace. \(summaryText). \(detailText). \(remainingPercent)% remaining." + self.paceTooltipText = paceTooltipText + currentTooltipText = "Now · \(remainingPercent)% remaining" + resetTooltipText = "Reset · \(resetText)" + projectedEmptyTooltipText = "Projected empty · \(projectedText) at current pace" + accessibilityLabel = "\(windowLabel) pace. \(paceTooltipText). \(summaryText). \(detailText). \(remainingPercent)% remaining." + } + + func tooltipText(for element: RateLimitProjectionGraphElement) -> String { + switch element { + case .pace: + return paceTooltipText + case .current: + return currentTooltipText + case .currentAndProjectedEmpty: + return "Now · \(currentRemainingPercent)% remaining · projected empty" + case .reset: + return resetTooltipText + case .projectedEmpty: + return projectedEmptyTooltipText + } } private static func clampedPosition(for date: Date, start: Date, durationSeconds: TimeInterval) -> Double { @@ -305,6 +348,23 @@ struct RateLimitProjectionViewData: Equatable, Sendable { return min(max(date.timeIntervalSince(start) / durationSeconds, 0), 1) } + + private static func paceTooltipText( + dailyUsePercent: Double, + windowDurationMins: Int, + locale: Locale + ) -> String { + let usesHourlyPace = windowDurationMins < 1_440 + let value = usesHourlyPace ? dailyUsePercent / 24 : dailyUsePercent + let formatter = NumberFormatter() + formatter.locale = locale + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = value < 1 ? 2 : 1 + let formattedValue = formatter.string(from: NSNumber(value: value)) ?? String(Int(value.rounded())) + let unit = usesHourlyPace ? "hour" : "day" + return "Current pace · \(formattedValue)% used per \(unit)" + } } struct RateLimitCardViewData: Equatable, Sendable { @@ -338,6 +398,7 @@ struct RateLimitCardViewData: Equatable, Sendable { } if let projection { + parts.append(projection.paceTooltipText) parts.append(projection.summaryText) parts.append(projection.detailText) } diff --git a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift index 306a330..610b7bd 100644 --- a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift +++ b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift @@ -381,26 +381,31 @@ actor CodexAppServerClient: CodexRateLimitClient { } nonisolated static func codexPathCandidates(environmentPath: String, homeDirectory: String) -> [String] { - let preferredAppCandidates = [ - "/Applications/ChatGPT.app/Contents/Resources/codex", - URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex").path, - "/Applications/Codex.app/Contents/Resources/codex", - URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/Codex.app/Contents/Resources/codex").path - ] - let pathCandidates = environmentPath.split(separator: ":").map { pathComponent in URL(fileURLWithPath: String(pathComponent)).appendingPathComponent("codex").path } - let fallbackCandidates = [ + let installedCLICandidates = [ URL(fileURLWithPath: homeDirectory).appendingPathComponent(".local/bin/codex").path, "/opt/homebrew/bin/codex", "/usr/local/bin/codex", "/usr/bin/codex" ] + // Prefer a user-installed CLI. Executables inside another app's bundle are + // private implementation details and may be replaced or quarantined when + // that app updates, so only use them when no standalone CLI is available. + let appBundleCandidates = [ + "/Applications/ChatGPT.app/Contents/Resources/codex", + URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex").path, + "/Applications/Codex.app/Contents/Resources/codex", + URL(fileURLWithPath: homeDirectory).appendingPathComponent("Applications/Codex.app/Contents/Resources/codex").path + ] + let appBundleCandidateSet = Set(appBundleCandidates) + let standalonePathCandidates = pathCandidates.filter { !appBundleCandidateSet.contains($0) } + var seen = Set() - let orderedCandidates = preferredAppCandidates + pathCandidates + fallbackCandidates + let orderedCandidates = standalonePathCandidates + installedCLICandidates + appBundleCandidates return orderedCandidates.filter { candidate in seen.insert(candidate).inserted diff --git a/Sources/CodexToolbar/Support/RateLimitStore.swift b/Sources/CodexToolbar/Support/RateLimitStore.swift index c7ae65a..730170e 100644 --- a/Sources/CodexToolbar/Support/RateLimitStore.swift +++ b/Sources/CodexToolbar/Support/RateLimitStore.swift @@ -435,7 +435,8 @@ final class RateLimitStore { window: entry.window, displayLabelOverride: displayLabelOverride( role: entry.role, - limitId: entry.limitId + limitId: entry.limitId, + windowDurationMins: entry.window.windowDurationMins ), familyId: entry.limitId, categoryLabel: entry.categoryLabel, @@ -577,9 +578,12 @@ final class RateLimitStore { private static func displayLabelOverride( role: WindowRole, - limitId: String? + limitId: String?, + windowDurationMins: Int? ) -> String? { - guard limitId == GetAccountRateLimitsResponse.codexLimitId else { + guard limitId == GetAccountRateLimitsResponse.codexLimitId, + windowDurationMins == nil + else { return nil } diff --git a/Sources/CodexToolbar/Views/StatusMenuContentView.swift b/Sources/CodexToolbar/Views/StatusMenuContentView.swift index 972c31b..d42c6c0 100644 --- a/Sources/CodexToolbar/Views/StatusMenuContentView.swift +++ b/Sources/CodexToolbar/Views/StatusMenuContentView.swift @@ -340,6 +340,7 @@ private struct RateLimitProjectionView: View { VStack(alignment: .leading, spacing: 3) { RateLimitProjectionSparkline(projection: projection, palette: palette) .frame(height: 31) + .zIndex(1) Text(projection.detailText) .font(.body) @@ -364,62 +365,66 @@ private struct RateLimitProjectionSparkline: View { let projection: RateLimitProjectionViewData let palette: StatusMenuPalette + @State private var hoverTarget: RateLimitProjectionGraphLayout.HoverTarget? + var body: some View { GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height - let horizontalInset: CGFloat = 2 - let verticalInset: CGFloat = 2 - let chartWidth = max(width - horizontalInset * 2, 1) - let chartHeight = max(height - verticalInset * 2, 1) - let topY = verticalInset - let bottomY = verticalInset + chartHeight - let startPoint = CGPoint(x: horizontalInset, y: topY) - let currentPoint = point( - xPosition: projection.currentPosition, - remainingPercent: projection.currentRemainingPercent, - horizontalInset: horizontalInset, - chartWidth: chartWidth, - topY: topY, - chartHeight: chartHeight - ) - let emptyPoint = CGPoint( - x: horizontalInset + chartWidth * CGFloat(projection.projectedEmptyPosition), - y: bottomY - ) - let resetX = horizontalInset + chartWidth * CGFloat(projection.resetPosition) + let layout = RateLimitProjectionGraphLayout(size: geometry.size, projection: projection) ZStack(alignment: .topLeading) { Path { path in - path.move(to: CGPoint(x: horizontalInset, y: bottomY)) - path.addLine(to: CGPoint(x: width - horizontalInset, y: bottomY)) + path.move(to: CGPoint(x: layout.horizontalInset, y: layout.bottomY)) + path.addLine(to: CGPoint(x: geometry.size.width - layout.horizontalInset, y: layout.bottomY)) } .stroke(palette.divider.opacity(0.55), lineWidth: 0.75) Path { path in - path.move(to: CGPoint(x: resetX, y: topY)) - path.addLine(to: CGPoint(x: resetX, y: bottomY)) + path.move(to: CGPoint(x: layout.resetX, y: layout.topY)) + path.addLine(to: CGPoint(x: layout.resetX, y: layout.bottomY)) } .stroke(palette.secondaryText.opacity(0.48), style: StrokeStyle(lineWidth: 0.75, dash: [2, 2])) Path { path in - path.move(to: startPoint) - path.addLine(to: currentPoint) - path.addLine(to: emptyPoint) + path.move(to: layout.startPoint) + path.addLine(to: layout.currentPoint) + path.addLine(to: layout.emptyPoint) } .stroke(statusColor, style: StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round)) Circle() .fill(statusColor) .frame(width: 4, height: 4) - .position(currentPoint) + .position(layout.currentPoint) Circle() .stroke(statusColor, lineWidth: 1.25) .frame(width: 5, height: 5) - .position(emptyPoint) + .position(layout.emptyPoint) + + if let hoverTarget { + RateLimitProjectionTooltip( + text: projection.tooltipText(for: hoverTarget.element), + palette: palette + ) + .position(layout.tooltipPosition(for: hoverTarget)) + .allowsHitTesting(false) + .zIndex(1) + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + hoverTarget = layout.hoverTarget(at: location) + case .ended: + hoverTarget = nil + } } } + .onDisappear { + hoverTarget = nil + } .accessibilityHidden(true) } @@ -427,19 +432,137 @@ private struct RateLimitProjectionSparkline: View { projectionColor(for: projection.state, palette: palette) } - private func point( - xPosition: Double, - remainingPercent: Int, - horizontalInset: CGFloat, - chartWidth: CGFloat, - topY: CGFloat, - chartHeight: CGFloat - ) -> CGPoint { - let remainingRatio = min(max(CGFloat(remainingPercent) / 100, 0), 1) - return CGPoint( - x: horizontalInset + chartWidth * CGFloat(xPosition), +} + +struct RateLimitProjectionGraphLayout { + struct HoverTarget: Equatable { + let element: RateLimitProjectionGraphElement + let anchor: CGPoint + } + + let size: CGSize + let horizontalInset: CGFloat = 2 + let verticalInset: CGFloat = 2 + let chartWidth: CGFloat + let chartHeight: CGFloat + let topY: CGFloat + let bottomY: CGFloat + let startPoint: CGPoint + let currentPoint: CGPoint + let emptyPoint: CGPoint + let resetX: CGFloat + + init(size: CGSize, projection: RateLimitProjectionViewData) { + self.size = size + chartWidth = max(size.width - horizontalInset * 2, 1) + chartHeight = max(size.height - verticalInset * 2, 1) + topY = verticalInset + bottomY = verticalInset + chartHeight + startPoint = CGPoint(x: horizontalInset, y: topY) + + let remainingRatio = min(max(CGFloat(projection.currentRemainingPercent) / 100, 0), 1) + currentPoint = CGPoint( + x: horizontalInset + chartWidth * CGFloat(projection.currentPosition), y: topY + chartHeight * (1 - remainingRatio) ) + emptyPoint = CGPoint( + x: horizontalInset + chartWidth * CGFloat(projection.projectedEmptyPosition), + y: bottomY + ) + resetX = horizontalInset + chartWidth * CGFloat(projection.resetPosition) + } + + func hoverTarget(at location: CGPoint) -> HoverTarget? { + if distance(from: currentPoint, to: emptyPoint) <= 1, + distance(from: location, to: currentPoint) <= 9 + { + return HoverTarget(element: .currentAndProjectedEmpty, anchor: currentPoint) + } + + let pointTargets = [ + HoverTarget(element: .current, anchor: currentPoint), + HoverTarget(element: .projectedEmpty, anchor: emptyPoint) + ] + if let closestPointTarget = pointTargets.min(by: { + distance(from: location, to: $0.anchor) < distance(from: location, to: $1.anchor) + }), distance(from: location, to: closestPointTarget.anchor) <= 9 { + return closestPointTarget + } + + if abs(location.x - resetX) <= 6 { + return HoverTarget( + element: .reset, + anchor: CGPoint(x: resetX, y: min(max(location.y, topY), bottomY)) + ) + } + + let paceAnchors = [ + closestPoint(onLineFrom: startPoint, to: currentPoint, to: location), + closestPoint(onLineFrom: currentPoint, to: emptyPoint, to: location) + ] + if let closestPaceAnchor = paceAnchors.min(by: { + distance(from: location, to: $0) < distance(from: location, to: $1) + }), distance(from: location, to: closestPaceAnchor) <= 7 { + return HoverTarget(element: .pace, anchor: closestPaceAnchor) + } + + return nil + } + + func tooltipPosition(for target: HoverTarget) -> CGPoint { + let horizontalLimit = min(size.width / 2, 118) + let x = min(max(target.anchor.x, horizontalLimit), size.width - horizontalLimit) + let y = target.anchor.y > size.height / 2 ? topY - 13 : bottomY + 13 + return CGPoint(x: x, y: y) + } + + private func closestPoint(onLineFrom start: CGPoint, to end: CGPoint, to point: CGPoint) -> CGPoint { + let deltaX = end.x - start.x + let deltaY = end.y - start.y + let lengthSquared = deltaX * deltaX + deltaY * deltaY + guard lengthSquared > 0 else { + return start + } + + let projectedPosition = ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / lengthSquared + let clampedPosition = min(max(projectedPosition, 0), 1) + return CGPoint( + x: start.x + clampedPosition * deltaX, + y: start.y + clampedPosition * deltaY + ) + } + + private func distance(from first: CGPoint, to second: CGPoint) -> CGFloat { + let deltaX = first.x - second.x + let deltaY = first.y - second.y + return sqrt(deltaX * deltaX + deltaY * deltaY) + } +} + +private struct RateLimitProjectionTooltip: View { + let text: String + let palette: StatusMenuPalette + + var body: some View { + Text(text) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.primaryText) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: 220) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(palette.surface ?? Color(nsColor: .windowBackgroundColor)) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(palette.divider, lineWidth: 0.75) + } + } + .shadow(color: .black.opacity(0.18), radius: 3, y: 1) + .accessibilityHidden(true) } } diff --git a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift index 4d4c3d9..d63ef85 100644 --- a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift +++ b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift @@ -15,12 +15,12 @@ final class CodexAppServerClientTests: XCTestCase { XCTAssertEqual( candidates.prefix(6), [ - "/Applications/ChatGPT.app/Contents/Resources/codex", - "/Users/example/Applications/ChatGPT.app/Contents/Resources/codex", - "/Applications/Codex.app/Contents/Resources/codex", - "/Users/example/Applications/Codex.app/Contents/Resources/codex", "/usr/bin/codex", - "/bin/codex" + "/bin/codex", + "/Users/example/.local/bin/codex", + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + "/Applications/ChatGPT.app/Contents/Resources/codex" ] ) XCTAssertTrue(candidates.contains("/Applications/ChatGPT.app/Contents/Resources/codex")) @@ -32,6 +32,18 @@ final class CodexAppServerClientTests: XCTestCase { XCTAssertTrue(candidates.contains("/usr/local/bin/codex")) } + func testCodexPathCandidatesPreferInstalledCLIOverAppBundleExecutables() throws { + let candidates = CodexAppServerClient.codexPathCandidates( + environmentPath: "/Applications/ChatGPT.app/Contents/Resources:/opt/homebrew/bin", + homeDirectory: "/Users/example" + ) + + XCTAssertLessThan( + try XCTUnwrap(candidates.firstIndex(of: "/opt/homebrew/bin/codex")), + try XCTUnwrap(candidates.firstIndex(of: "/Applications/ChatGPT.app/Contents/Resources/codex")) + ) + } + func testCodexPathCandidatesDeduplicateEntries() { let candidates = CodexAppServerClient.codexPathCandidates( environmentPath: "/Applications/ChatGPT.app/Contents/Resources:/opt/homebrew/bin:/Applications/ChatGPT.app/Contents/Resources", diff --git a/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift b/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift index beab447..3e8a2e1 100644 --- a/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift +++ b/Tests/CodexToolbarTests/RateLimitCardViewDataTests.swift @@ -65,8 +65,17 @@ final class RateLimitCardViewDataTests: XCTestCase { XCTAssertEqual(projection.currentPosition, 0.25, accuracy: 0.001) XCTAssertEqual(projection.resetPosition, 0.875, accuracy: 0.001) XCTAssertEqual(projection.projectedEmptyPosition, 1.0, accuracy: 0.001) + XCTAssertEqual(projection.paceTooltipText, "Current pace · 12.5% used per day") + XCTAssertEqual(projection.currentTooltipText, "Now · 75% remaining") + XCTAssertEqual(projection.resetTooltipText, "Reset · May 18") + XCTAssertEqual(projection.projectedEmptyTooltipText, "Projected empty · May 19 at current pace") + XCTAssertEqual(projection.tooltipText(for: .pace), projection.paceTooltipText) + XCTAssertEqual(projection.tooltipText(for: .current), projection.currentTooltipText) + XCTAssertEqual(projection.tooltipText(for: .reset), projection.resetTooltipText) + XCTAssertEqual(projection.tooltipText(for: .projectedEmpty), projection.projectedEmptyTooltipText) XCTAssertTrue(card.accessibilityLabel.contains("Reset comes first")) XCTAssertTrue(card.accessibilityLabel.contains("On pace to last through reset")) + XCTAssertTrue(card.accessibilityLabel.contains(projection.paceTooltipText)) XCTAssertTrue(projection.accessibilityLabel.contains("Weekly pace")) } @@ -84,9 +93,45 @@ final class RateLimitCardViewDataTests: XCTestCase { XCTAssertEqual(projection.currentPosition, 0.1, accuracy: 0.001) XCTAssertEqual(projection.resetPosition, 0.5, accuracy: 0.001) XCTAssertEqual(projection.projectedEmptyPosition, 1.0, accuracy: 0.001) + XCTAssertEqual(projection.paceTooltipText, "Current pace · 10% used per hour") + XCTAssertEqual(projection.currentTooltipText, "Now · 90% remaining") + XCTAssertEqual(projection.resetTooltipText, "Reset · 4:00 PM") + XCTAssertEqual(projection.projectedEmptyTooltipText, "Projected empty · 9:00 PM at current pace") XCTAssertTrue(projection.accessibilityLabel.contains("5h pace")) } + func testProjectionGraphLayoutTargetsMarkersResetAndPaceWithoutTargetingBaseline() throws { + let now = makeDate(year: 2026, month: 5, day: 13, hour: 12, minute: 0) + let reset = makeDate(year: 2026, month: 5, day: 18, hour: 12, minute: 0) + let projection = try XCTUnwrap(makeWeeklyCard(usedPercent: 25, reset: reset, now: now).projection) + let layout = RateLimitProjectionGraphLayout( + size: CGSize(width: 320, height: 31), + projection: projection + ) + + XCTAssertEqual(layout.hoverTarget(at: layout.currentPoint)?.element, .current) + XCTAssertEqual( + layout.hoverTarget(at: CGPoint(x: layout.currentPoint.x + 8, y: layout.currentPoint.y))?.element, + .current + ) + let emptyTarget = try XCTUnwrap(layout.hoverTarget(at: layout.emptyPoint)) + XCTAssertEqual(emptyTarget.element, .projectedEmpty) + XCTAssertEqual(layout.tooltipPosition(for: emptyTarget).x, 202, accuracy: 0.001) + XCTAssertEqual( + layout.hoverTarget(at: CGPoint(x: layout.resetX, y: layout.topY))?.element, + .reset + ) + + let pacePoint = CGPoint( + x: (layout.startPoint.x + layout.currentPoint.x) / 2, + y: (layout.startPoint.y + layout.currentPoint.y) / 2 + ) + XCTAssertEqual(layout.hoverTarget(at: pacePoint)?.element, .pace) + + let baselinePoint = CGPoint(x: layout.horizontalInset + 20, y: layout.bottomY) + XCTAssertNil(layout.hoverTarget(at: baselinePoint)) + } + func testFiveHourProjectionIsCriticalWhenProjectedEmptyFallsBeforeReset() throws { let now = makeDate(year: 2026, month: 5, day: 13, hour: 12, minute: 0) let reset = makeDate(year: 2026, month: 5, day: 13, hour: 16, minute: 0) @@ -135,6 +180,14 @@ final class RateLimitCardViewDataTests: XCTestCase { XCTAssertEqual(projection.state, .critical) XCTAssertEqual(projection.currentRemainingPercent, 0) XCTAssertEqual(projection.projectedEmptyDate, now) + + let layout = RateLimitProjectionGraphLayout( + size: CGSize(width: 320, height: 31), + projection: projection + ) + let target = try XCTUnwrap(layout.hoverTarget(at: layout.currentPoint)) + XCTAssertEqual(target.element, .currentAndProjectedEmpty) + XCTAssertEqual(projection.tooltipText(for: target.element), "Now · 0% remaining · projected empty") } func testProjectionIsHiddenWhenInputsAreUnavailable() { diff --git a/Tests/CodexToolbarTests/RateLimitStoreTests.swift b/Tests/CodexToolbarTests/RateLimitStoreTests.swift index ea9496e..cecf904 100644 --- a/Tests/CodexToolbarTests/RateLimitStoreTests.swift +++ b/Tests/CodexToolbarTests/RateLimitStoreTests.swift @@ -478,12 +478,88 @@ final class RateLimitStoreTests: XCTestCase { secondary: CodexRateLimitWindow(resetsAt: 1_741_731_200, usedPercent: 41, windowDurationMins: 10_081) ) + let cards = RateLimitStore.makeCards(from: snapshot) + let weeklyCard = cards.first { $0.usedPercent == 41 } + + XCTAssertEqual(cards.count, 2) + XCTAssertEqual(weeklyCard?.compactLabel, "Weekly") + XCTAssertEqual(weeklyCard?.title, "Weekly") + } + + func testMakeCardsUsesDurationWhenWeeklyWindowIsPrimary() { + let snapshot = CodexRateLimitsSnapshot( + credits: nil, + limitId: "codex", + limitName: "Codex", + planType: .pro, + primary: CodexRateLimitWindow( + resetsAt: 1_741_731_200, + usedPercent: 2, + windowDurationMins: 10_080 + ), + secondary: nil + ) + let cards = RateLimitStore.makeCards(from: snapshot) XCTAssertEqual(cards[0].compactLabel, "Weekly") XCTAssertEqual(cards[0].title, "Weekly") } + func testMakeCardsFallsBackToCoreCodexRolesWhenDurationsAreMissing() { + let snapshot = CodexRateLimitsSnapshot( + credits: nil, + limitId: "codex", + limitName: "Codex", + planType: .pro, + primary: CodexRateLimitWindow( + resetsAt: 1_741_171_240, + usedPercent: 12, + windowDurationMins: nil + ), + secondary: CodexRateLimitWindow( + resetsAt: 1_741_731_200, + usedPercent: 41, + windowDurationMins: nil + ) + ) + + let cards = RateLimitStore.makeCards(from: snapshot) + + let primaryCard = cards.first { $0.usedPercent == 12 } + let secondaryCard = cards.first { $0.usedPercent == 41 } + + XCTAssertEqual(primaryCard?.compactLabel, "5h") + XCTAssertEqual(primaryCard?.title, "5h") + XCTAssertEqual(secondaryCard?.compactLabel, "Weekly") + XCTAssertEqual(secondaryCard?.title, "Weekly") + } + + func testMakeCardsUsesDurationWhenTwoWeekWindowIsSecondary() { + let snapshot = CodexRateLimitsSnapshot( + credits: nil, + limitId: "codex", + limitName: "Codex", + planType: .pro, + primary: CodexRateLimitWindow( + resetsAt: 1_741_171_240, + usedPercent: 12, + windowDurationMins: 300 + ), + secondary: CodexRateLimitWindow( + resetsAt: 1_742_336_000, + usedPercent: 41, + windowDurationMins: 20_160 + ) + ) + + let cards = RateLimitStore.makeCards(from: snapshot) + let secondaryCard = cards.first { $0.usedPercent == 41 } + + XCTAssertEqual(secondaryCard?.compactLabel, "2 Week") + XCTAssertEqual(secondaryCard?.title, "2 Week") + } + func testMakeCardsKeepsNonCodexFormatterLabels() { let snapshot = CodexRateLimitsSnapshot( credits: nil,