From 867b80c0bef69b34f5ee97d9ee4290669dd56b2c Mon Sep 17 00:00:00 2001 From: 666ghj <110395318+666ghj@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:46:31 +0800 Subject: [PATCH] Add hover details to inline cost charts --- .../InlineUsageDashboardContent.swift | 237 +++++++++++++++--- .../InlineCostHistoryCalendarTests.swift | 210 +++++++++++++++- ...InlineCostHistoryDashboardLabelTests.swift | 55 +++- Tests/CodexBarTests/MenuCardModelTests.swift | 8 +- .../ProviderArchitectureGatekeeperTests.swift | 2 +- 5 files changed, 459 insertions(+), 53 deletions(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 6e6f97efbe..e567ca36f5 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -2,6 +2,19 @@ import CodexBarCore import SwiftUI struct InlineUsageDashboardModel: Equatable { + struct HoverDetail: Equatable { + let dateLabel: String + let cost: Double? + let tokenCount: Int? + let currencyCode: String + + var summary: String { + let cost = self.cost.map { UsageFormatter.currencyString($0, currencyCode: self.currencyCode) } ?? "—" + let tokens = self.tokenCount.map(UsageFormatter.tokenCountString) ?? "—" + return L("%@: %@ · %@ tokens", self.dateLabel, cost, tokens) + } + } + struct KPI: Equatable { let title: String let value: String @@ -13,6 +26,7 @@ struct InlineUsageDashboardModel: Equatable { let label: String let value: Double? let accessibilityValue: String + var hoverDetail: HoverDetail? } enum ValueStyle: Equatable { @@ -189,18 +203,14 @@ extension UsageMenuCardView.Model { } else { L("%@ cost", historyDays == 1 ? L("Today") : String(format: L("Last %d days"), historyDays)) } - let points = Self.inlineCostHistoryDays( - snapshot: snapshot, - historyDays: historyDays, - preservesCalendarDays: tokenCost.preservesCalendarDaysInCharts, - calendar: calendar) - .map { day in - InlineUsageDashboardModel.Point( - id: day.date, - label: Self.shortDayLabel(day.date), - value: day.costUSD.map(convertedValue), - accessibilityValue: "\(day.date): \(day.costUSD.map(convertedString) ?? L("Unknown"))") - } + let points = Self.inlineCostHistoryPoints( + days: Self.inlineCostHistoryDays( + snapshot: snapshot, + historyDays: historyDays, + preservesCalendarDays: tokenCost.preservesCalendarDaysInCharts, + calendar: calendar), + displayCurrencyCode: displayCurrencyCode, + convertedValue: convertedValue) let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) let usesLatestPrimary = tokenCost.primaryValue == .latestDaily let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD @@ -346,12 +356,13 @@ extension UsageMenuCardView.Model { historyDays: Int, preservesCalendarDays: Bool, calendar sourceCalendar: Calendar) - -> [(date: String, costUSD: Double?)] + -> [(date: String, costUSD: Double?, totalTokens: Int?)] { - let existingDays = snapshot.daily.suffix(historyDays).compactMap { entry -> (String, Double?)? in - guard let cost = entry.costUSD else { return nil } - return (entry.date, cost) - } + let existingDays = snapshot.daily.suffix(historyDays) + .compactMap { entry -> (date: String, costUSD: Double?, totalTokens: Int?)? in + guard entry.costUSD != nil || entry.totalTokens != nil else { return nil } + return (entry.date, entry.costUSD, entry.totalTokens) + } guard preservesCalendarDays else { return existingDays } var calendar = Calendar(identifier: .gregorian) @@ -366,10 +377,40 @@ extension UsageMenuCardView.Model { guard let date = calendar.date(byAdding: .day, value: offset, to: startDate) else { return nil } let dayKey = Self.inlineCostHistoryDayKey(date, calendar: calendar) if let entry = entriesByDay[dayKey] { - return (date: dayKey, costUSD: entry.costUSD) + return (date: dayKey, costUSD: entry.costUSD, totalTokens: entry.totalTokens) } // A missing date is zero only after the scan has covered the requested history. - return (date: dayKey, costUSD: snapshot.historyCoverageIsEstablished ? 0 : nil) + return ( + date: dayKey, + costUSD: snapshot.historyCoverageIsEstablished ? 0 : nil, + totalTokens: snapshot.historyCoverageIsEstablished ? 0 : nil) + } + } + + private static func inlineCostHistoryPoints( + days: [(date: String, costUSD: Double?, totalTokens: Int?)], + displayCurrencyCode: String, + convertedValue: (Double) -> Double) -> [InlineUsageDashboardModel.Point] + { + days.map { day in + let costUSD = day.costUSD.flatMap { $0 >= 0 ? $0 : nil } + let tokenCount = day.totalTokens.flatMap { $0 >= 0 ? $0 : nil } + let convertedCost = costUSD.map(convertedValue) + let hoverDetail: InlineUsageDashboardModel.HoverDetail? = if costUSD != nil || tokenCount != nil { + .init( + dateLabel: day.date, + cost: convertedCost, + tokenCount: tokenCount, + currencyCode: displayCurrencyCode) + } else { + nil + } + return InlineUsageDashboardModel.Point( + id: day.date, + label: Self.shortDayLabel(day.date), + value: convertedCost, + accessibilityValue: hoverDetail?.summary ?? "\(day.date): \(L("Unknown"))", + hoverDetail: hoverDetail) } } @@ -481,39 +522,103 @@ struct InlineUsageDashboardContent: View { private struct MiniUsageBars: View { let model: InlineUsageDashboardModel @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.layoutDirection) private var layoutDirection + @State private var selectedPointID: String? var body: some View { let scale = UsageChartScale(values: self.model.points.compactMap(\.value)) + let hoverDetail = self.model.points + .first(where: { $0.id == self.selectedPointID })? + .hoverDetail VStack(alignment: .trailing, spacing: 2) { - if let currencyCode = self.model.currencyCode, scale.maximum > 0 { - Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) + if let currencyCode = self.model.currencyCode { + let scaleLabel = scale.maximum > 0 + ? UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode) + : " " + Text(hoverDetail?.summary ?? scaleLabel) .font(.caption2) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .monospacedDigit() .lineLimit(1) + .minimumScaleFactor(0.72) .allowsTightening(true) + .frame(maxWidth: .infinity, alignment: hoverDetail == nil ? .trailing : .leading) + .opacity(hoverDetail != nil || scale.maximum > 0 ? 1 : 0) + .accessibilityHidden(hoverDetail != nil) } GeometryReader { geometry in let layout = InlineUsageBarLayout(width: geometry.size.width, count: self.model.points.count) - HStack(alignment: .bottom, spacing: layout.spacing) { - ForEach(self.model.points) { point in - RoundedRectangle(cornerRadius: 1.5, style: .continuous) - .fill(self.fill(for: point, scale: scale)) - .frame(width: layout.barWidth) - .frame(height: self.height(for: point, scale: scale, available: geometry.size.height)) - .accessibilityLabel(point.accessibilityValue) + ZStack { + HStack(alignment: .bottom, spacing: layout.spacing) { + ForEach(self.model.points) { point in + let barHeight = self.height( + for: point, + scale: scale, + available: geometry.size.height) + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(self.fill(for: point, scale: scale)) + .frame(width: layout.barWidth) + .frame(height: barHeight) + .overlay { + if point.id == self.selectedPointID { + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .strokeBorder( + MenuHighlightStyle.primary(self.isHighlighted), + lineWidth: layout.selectionStrokeWidth(barHeight: barHeight)) + } + } + .accessibilityLabel(point.accessibilityValue) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .overlay(alignment: .bottomLeading) { + Rectangle() + .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) + .frame(height: 1) + } + + if self.model.points.contains(where: { $0.hoverDetail != nil }) { + MouseLocationReader { location in + self.updateSelection(location: location, layout: layout) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .overlay(alignment: .bottomLeading) { - Rectangle() - .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) - .frame(height: 1) + .onChange(of: geometry.size.width) { _, _ in + self.clearSelection() + } + .onChange(of: self.model.points) { previousPoints, points in + let nextPointID = InlineUsageBarHoverSelection.reconciledPointID( + current: self.selectedPointID, + previousPoints: previousPoints, + points: points) + guard self.selectedPointID != nextPointID else { return } + self.selectedPointID = nextPointID + } + .onChange(of: self.layoutDirection) { _, _ in + self.clearSelection() } } } } + private func updateSelection(location: CGPoint?, layout: InlineUsageBarLayout) { + let nextPointID = InlineUsageBarHoverSelection.pointID( + current: self.selectedPointID, + locationX: location?.x, + layout: layout, + layoutDirection: self.layoutDirection, + points: self.model.points) + guard self.selectedPointID != nextPointID else { return } + self.selectedPointID = nextPointID + } + + private func clearSelection() { + guard self.selectedPointID != nil else { return } + self.selectedPointID = nil + } + private func height( for point: InlineUsageDashboardModel.Point, scale: UsageChartScale, @@ -553,11 +658,69 @@ struct InlineUsageDashboardContent: View { struct InlineUsageBarLayout { let spacing: CGFloat let barWidth: CGFloat + private let width: CGFloat + private let barCount: Int init(width: CGFloat, count: Int) { - let count = max(1, count) - let width = max(0, width) - self.spacing = count == 1 ? 0 : min(2, width / CGFloat(count) / 4) - self.barWidth = max(0, (width - self.spacing * CGFloat(count - 1)) / CGFloat(count)) + self.width = max(0, width) + self.barCount = max(0, count) + let layoutCount = max(1, self.barCount) + self.spacing = self.barCount <= 1 ? 0 : min(2, self.width / CGFloat(layoutCount) / 4) + self.barWidth = self.barCount == 0 + ? 0 + : max(0, (self.width - self.spacing * CGFloat(self.barCount - 1)) / CGFloat(self.barCount)) + } + + func selectionStrokeWidth(barHeight: CGFloat) -> CGFloat { + min(1, self.barWidth / 2, max(0, barHeight) / 2) + } + + func contains(_ locationX: CGFloat) -> Bool { + self.barCount > 0 && self.width > 0 && locationX >= 0 && locationX <= self.width + } + + func index(atX locationX: CGFloat, layoutDirection: LayoutDirection = .leftToRight) -> Int? { + guard self.contains(locationX), self.barWidth > 0 else { return nil } + + let stride = self.barWidth + self.spacing + guard stride > 0 else { return nil } + let resolvedX = layoutDirection == .rightToLeft ? self.width - locationX : locationX + if self.spacing < 1 { + let nearest = Int(((resolvedX - self.barWidth / 2) / stride).rounded()) + return min(max(nearest, 0), self.barCount - 1) + } + let index = min(Int(resolvedX / stride), self.barCount - 1) + let offset = resolvedX - CGFloat(index) * stride + let tolerance = max(1, self.width) * CGFloat.ulpOfOne * 8 + return offset <= self.barWidth + tolerance ? index : nil + } +} + +enum InlineUsageBarHoverSelection { + static func reconciledPointID( + current: String?, + previousPoints: [InlineUsageDashboardModel.Point], + points: [InlineUsageDashboardModel.Point]) -> String? + { + guard previousPoints.map(\.id) == points.map(\.id), let current else { return nil } + return points.contains { $0.id == current && $0.hoverDetail != nil } ? current : nil + } + + static func pointID( + current: String?, + locationX: CGFloat?, + layout: InlineUsageBarLayout, + layoutDirection: LayoutDirection, + points: [InlineUsageDashboardModel.Point]) -> String? + { + guard let locationX else { return nil } + guard layout.contains(locationX) else { return nil } + guard let index = layout.index(atX: locationX, layoutDirection: layoutDirection) else { + return current.flatMap { currentID in + points.contains { $0.id == currentID && $0.hoverDetail != nil } ? currentID : nil + } + } + guard points.indices.contains(index), points[index].hoverDetail != nil else { return nil } + return points[index].id } } diff --git a/Tests/CodexBarTests/InlineCostHistoryCalendarTests.swift b/Tests/CodexBarTests/InlineCostHistoryCalendarTests.swift index f5f9d79ccc..5e69910843 100644 --- a/Tests/CodexBarTests/InlineCostHistoryCalendarTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryCalendarTests.swift @@ -14,7 +14,8 @@ struct InlineCostHistoryCalendarTests { let points = try InlineCostCalendarFixture.model(snapshot).points #expect(points.map(\.id) == ["2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24"]) #expect(points.map { $0.value as Double? } == [3, 0, nil, 4]) - #expect(points[2].accessibilityValue == "2026-08-23: Unknown") + #expect(points[2].accessibilityValue == "2026-08-23: — · 100 tokens") + #expect(points.map { $0.hoverDetail?.tokenCount } == [100, 0, 100, 100]) } @Test @@ -26,17 +27,38 @@ struct InlineCostHistoryCalendarTests { let points = try InlineCostCalendarFixture.model(snapshot).points #expect(points.map { $0.value as Double? } == [3, nil, nil, 4]) #expect(!points.contains { $0.accessibilityValue.contains("$0.00") }) + #expect(points.map { $0.hoverDetail?.tokenCount } == [100, nil, 100, 100]) } @Test - func `other providers retain sparse priced histories`() throws { + func `other providers retain sparse histories with token only days`() throws { let snapshot = try InlineCostCalendarFixture.snapshot( now: InlineCostCalendarFixture.now(), days: 4, covered: true) let points = try InlineCostCalendarFixture.model(snapshot, provider: .claude).points - #expect(points.map(\.id) == ["2026-08-21", "2026-08-24"]) - #expect(points.map(\.value) == [3, 4]) + #expect(points.map(\.id) == ["2026-08-21", "2026-08-23", "2026-08-24"]) + #expect(points.map(\.value) == [3, nil, 4]) + #expect(points.map { $0.hoverDetail?.tokenCount } == [100, 100, 100]) + #expect(points[1].accessibilityValue == "2026-08-23: — · 100 tokens") + } + + @Test + func `negative cost and token values remain unknown`() throws { + let now = try InlineCostCalendarFixture.now() + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 0, + sessionCostUSD: 0, + last30DaysTokens: 0, + last30DaysCostUSD: 0, + historyDays: 1, + daily: [InlineCostCalendarFixture.entry("2026-08-24", cost: -1, totalTokens: -1)], + updatedAt: now) + let point = try #require(InlineCostCalendarFixture.model(snapshot, provider: .claude).points.first) + + #expect(point.value == nil) + #expect(point.hoverDetail == nil) + #expect(point.accessibilityValue == "2026-08-24: Unknown") } @Test(arguments: [ @@ -78,6 +100,136 @@ struct InlineCostHistoryCalendarTests { } } + @Test(arguments: [1, 4, 30, 90, 365]) + func `bar hover resolves dynamic geometry and right to left order`(count: Int) { + let width: CGFloat = 286 + let layout = InlineUsageBarLayout(width: width, count: count) + let firstCenter = layout.barWidth / 2 + let lastCenter = CGFloat(count - 1) * (layout.barWidth + layout.spacing) + layout.barWidth / 2 + + #expect(layout.index(atX: firstCenter) == 0) + #expect(layout.index(atX: lastCenter) == count - 1) + #expect(layout.index(atX: firstCenter, layoutDirection: .rightToLeft) == count - 1) + #expect(layout.index(atX: lastCenter, layoutDirection: .rightToLeft) == 0) + #expect(layout.index(atX: 0) == 0) + #expect(layout.index(atX: width) == count - 1) + #expect(layout.index(atX: -1) == nil) + #expect(layout.index(atX: width + 1) == nil) + + if count > 1, layout.spacing > 0 { + let gap = layout.barWidth + layout.spacing / 2 + if layout.spacing < 1 { + #expect(layout.index(atX: gap) != nil) + } else { + #expect(layout.index(atX: gap) == nil) + } + } + let selectionStrokeWidth = layout.selectionStrokeWidth(barHeight: 1) + #expect(selectionStrokeWidth <= layout.barWidth / 2) + #expect(selectionStrokeWidth <= 0.5) + } + + @Test + func `empty bar layout never selects a point`() { + let layout = InlineUsageBarLayout(width: 286, count: 0) + #expect(layout.barWidth == 0) + #expect(layout.index(atX: 0) == nil) + #expect(layout.index(atX: 143) == nil) + } + + @Test + func `hover selection retains a bar across gaps and clears on exit or unknown days`() { + let detail = InlineUsageDashboardModel.HoverDetail( + dateLabel: "2026-08-21", + cost: 3, + tokenCount: 100, + currencyCode: "USD") + let points = [ + InlineUsageDashboardModel.Point( + id: "first", + label: "1", + value: 3, + accessibilityValue: detail.summary, + hoverDetail: detail), + InlineUsageDashboardModel.Point( + id: "unknown", + label: "2", + value: nil, + accessibilityValue: "Unknown"), + InlineUsageDashboardModel.Point( + id: "last", + label: "3", + value: 4, + accessibilityValue: detail.summary, + hoverDetail: detail), + ] + let layout = InlineUsageBarLayout(width: 32, count: points.count) + let firstCenter = layout.barWidth / 2 + let gap = layout.barWidth + layout.spacing / 2 + let unknownCenter = layout.barWidth + layout.spacing + layout.barWidth / 2 + + let first = InlineUsageBarHoverSelection.pointID( + current: nil, + locationX: firstCenter, + layout: layout, + layoutDirection: .leftToRight, + points: points) + #expect(first == "first") + #expect(InlineUsageBarHoverSelection.pointID( + current: first, + locationX: gap, + layout: layout, + layoutDirection: .leftToRight, + points: points) == "first") + #expect(InlineUsageBarHoverSelection.pointID( + current: "stale", + locationX: gap, + layout: layout, + layoutDirection: .leftToRight, + points: points) == nil) + #expect(InlineUsageBarHoverSelection.pointID( + current: first, + locationX: unknownCenter, + layout: layout, + layoutDirection: .leftToRight, + points: points) == nil) + #expect(InlineUsageBarHoverSelection.pointID( + current: first, + locationX: nil, + layout: layout, + layoutDirection: .leftToRight, + points: points) == nil) + #expect(InlineUsageBarHoverSelection.pointID( + current: first, + locationX: 33, + layout: layout, + layoutDirection: .leftToRight, + points: points) == nil) + #expect(InlineUsageBarHoverSelection.pointID( + current: nil, + locationX: firstCenter, + layout: layout, + layoutDirection: .rightToLeft, + points: points) == "last") + + let refreshedPoints = points.map { point in + InlineUsageDashboardModel.Point( + id: point.id, + label: point.label, + value: point.value.map { $0 + 1 }, + accessibilityValue: point.accessibilityValue, + hoverDetail: point.hoverDetail) + } + #expect(InlineUsageBarHoverSelection.reconciledPointID( + current: first, + previousPoints: points, + points: refreshedPoints) == "first") + #expect(InlineUsageBarHoverSelection.reconciledPointID( + current: first, + previousPoints: points, + points: Array(points.dropFirst())) == nil) + } + @Test func `native bounded scan keeps unknown dates until catch up completes`() async throws { let (partial, covered) = try await InlineCostCalendarFixture.scannedSnapshots() @@ -123,12 +275,12 @@ enum InlineCostCalendarFixture { updatedAt: now) } - static func entry(_ date: String, cost: Double?) -> CostUsageDailyReport.Entry { + static func entry(_ date: String, cost: Double?, totalTokens: Int = 100) -> CostUsageDailyReport.Entry { .init( date: date, - inputTokens: 100, + inputTokens: totalTokens, outputTokens: 0, - totalTokens: 100, + totalTokens: totalTokens, costUSD: cost, modelsUsed: nil, modelBreakdowns: nil) @@ -261,7 +413,9 @@ final class InlineCostHistoryScreenshotTests: XCTestCase { for (stem, model) in models { for dark in [false, true] { let view = InlineUsageDashboardContent(model: model) - .padding(12).frame(width: 310) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .frame(width: 310) .environment(\.colorScheme, dark ? .dark : .light) .environment(\.displayScale, 2) .environment(\.accessibilityEnabled, true) @@ -286,8 +440,48 @@ final class InlineCostHistoryScreenshotTests: XCTestCase { } } XCTAssertEqual(hosting.bounds.width, 310, accuracy: 0.1) + + if stem == "calendar-4-covered" || stem == "calendar-365-covered" { + let trackingView = try XCTUnwrap(Self.firstTrackingView(in: hosting)) + XCTAssertGreaterThan(trackingView.bounds.width, 0) + let layout = InlineUsageBarLayout(width: trackingView.bounds.width, count: model.points.count) + let selectedIndex = model.points.count - 1 + let selectedX = CGFloat(selectedIndex) * (layout.barWidth + layout.spacing) + + layout.barWidth / 2 + trackingView.onMoved?(CGPoint(x: selectedX, y: trackingView.bounds.midY)) + try await Task.sleep(for: .milliseconds(50)) + hosting.layoutSubtreeIfNeeded() + + let hoveredRepresentation = try XCTUnwrap( + hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: hoveredRepresentation) + let hoveredData = try XCTUnwrap( + hoveredRepresentation.representation(using: .png, properties: [:])) + try hoveredData.write(to: directory.appendingPathComponent("\(name)-hover.png")) + XCTAssertNotEqual(data, hoveredData) + + let selectedDetail = try XCTUnwrap(model.points[selectedIndex].hoverDetail) + let hoveredAccessibility = Self.accessibilityText(hosting) + XCTAssertTrue(hoveredAccessibility.contains(selectedDetail.summary)) + XCTAssertEqual( + hoveredAccessibility.components(separatedBy: selectedDetail.summary).count - 1, + 1, + "The visual hover summary must not duplicate the selected bar's VoiceOver label.") + } + } + } + } + + private static func firstTrackingView(in root: NSView) -> MouseLocationReader.TrackingView? { + if let trackingView = root as? MouseLocationReader.TrackingView { + return trackingView + } + for subview in root.subviews { + if let trackingView = self.firstTrackingView(in: subview) { + return trackingView } } + return nil } private static func accessibilityText(_ element: Any, depth: Int = 0) -> String { diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 2947a31319..f35b8d2a17 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -50,7 +50,18 @@ struct InlineCostHistoryDashboardLabelTests { #expect(model.inlineUsageDashboard?.kpis.first?.title == "Today") #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: $0.25") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == + "2023-11-15: $0.25 · 275 tokens") + let hoverDetail = try #require(model.inlineUsageDashboard?.points.first?.hoverDetail) + #expect(hoverDetail == .init( + dateLabel: "2023-11-15", + cost: 0.25, + tokenCount: 275, + currencyCode: "USD")) + let summary = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + hoverDetail.summary + } + #expect(summary == "2023-11-15: $0.25 · 275 tokens") } @Test @@ -107,7 +118,32 @@ struct InlineCostHistoryDashboardLabelTests { #expect(model.inlineUsageDashboard?.currencyCode == "USD") #expect(model.inlineUsageDashboard?.kpis.first?.value == expected) #expect(model.inlineUsageDashboard?.points.first?.value == expectedValue) - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: \(expected)") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == + "2023-11-15: \(expected) · 100 tokens") + #expect(model.inlineUsageDashboard?.points.first?.hoverDetail == .init( + dateLabel: "2023-11-15", + cost: expectedValue, + tokenCount: 100, + currencyCode: "USD")) + } + + @Test + func `hover summary localizes the full sentence and preserves unknown values`() { + let detail = InlineUsageDashboardModel.HoverDetail( + dateLabel: "2023-11-15", + cost: nil, + tokenCount: nil, + currencyCode: "USD") + + let english = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + detail.summary + } + let simplifiedChinese = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + detail.summary + } + + #expect(english == "2023-11-15: — · — tokens") + #expect(simplifiedChinese == "2023-11-15:— · — token") } @Test @@ -389,11 +425,18 @@ struct InlineCostHistoryDashboardLabelTests { #expect(points.map(\.id) == ["2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24"]) #expect(points.map(\.value) == [3, 0, 0, 4]) #expect(points.map(\.accessibilityValue) == [ - "2026-08-21: $3.00", - "2026-08-22: $0.00", - "2026-08-23: $0.00", - "2026-08-24: $4.00", + "2026-08-21: $3.00 · 300 tokens", + "2026-08-22: $0.00 · 0 tokens", + "2026-08-23: $0.00 · 0 tokens", + "2026-08-24: $4.00 · 400 tokens", ]) + let hoverDetails: [InlineUsageDashboardModel.HoverDetail?] = [ + .init(dateLabel: "2026-08-21", cost: 3, tokenCount: 300, currencyCode: "USD"), + .init(dateLabel: "2026-08-22", cost: 0, tokenCount: 0, currencyCode: "USD"), + .init(dateLabel: "2026-08-23", cost: 0, tokenCount: 0, currencyCode: "USD"), + .init(dateLabel: "2026-08-24", cost: 4, tokenCount: 400, currencyCode: "USD"), + ] + #expect(points.map(\.hoverDetail) == hoverDetails) } @Test diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index afc8c4472d..377d975cc2 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -314,7 +314,13 @@ struct ProviderInlineDashboardModelTests { now: now)) #expect(model.inlineUsageDashboard?.kpis.first?.value == "€1.50") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.50") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == + "2023-11-14: €1.50 · 150 tokens") + #expect(model.inlineUsageDashboard?.points.first?.hoverDetail == .init( + dateLabel: "2023-11-14", + cost: 1.5, + tokenCount: 150, + currencyCode: "EUR")) #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: mistral-large") == true) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 126510716f..a4b1d39f36 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1790,7 +1790,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/InlineUsageDashboardContent.swift", - line: 268, + line: 278, anchor: "if provider == .cursor, let meteredCostUSD = snapshot.meteredCostUSD {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1,